feat(core): first class a11y support (#8909)

This commit is contained in:
Morten Sjøgren
2021-01-29 20:51:51 +01:00
committed by Nathan Walker
parent ef9c3b1f5f
commit c46da3fad9
43 changed files with 2938 additions and 47 deletions

View File

@ -0,0 +1,73 @@
import type { View } from '../ui/core/view';
import type { Page } from '../ui/page';
import type { AccessibilityBlurEventData, AccessibilityFocusChangedEventData, AccessibilityFocusEventData } from './accessibility-types';
const lastFocusedViewOnPageKeyName = '__lastFocusedViewOnPage';
export const accessibilityBlurEvent = 'accessibilityBlur';
export const accessibilityFocusEvent = 'accessibilityFocus';
export const accessibilityFocusChangedEvent = 'accessibilityFocusChanged';
/**
* Send notification when accessibility focus state changes.
* If either receivedFocus or lostFocus is true, 'accessibilityFocusChanged' is send with value true if element received focus
* If receivedFocus, 'accessibilityFocus' is send
* if lostFocus, 'accessibilityBlur' is send
*
* @param {View} view
* @param {boolean} receivedFocus
* @param {boolean} lostFocus
*/
export function notifyAccessibilityFocusState(view: View, receivedFocus: boolean, lostFocus: boolean): void {
if (!receivedFocus && !lostFocus) {
return;
}
view.notify({
eventName: accessibilityFocusChangedEvent,
object: view,
value: !!receivedFocus,
} as AccessibilityFocusChangedEventData);
if (receivedFocus) {
if (view.page) {
view.page[lastFocusedViewOnPageKeyName] = new WeakRef(view);
}
view.notify({
eventName: accessibilityFocusEvent,
object: view,
} as AccessibilityFocusEventData);
} else if (lostFocus) {
view.notify({
eventName: accessibilityBlurEvent,
object: view,
} as AccessibilityBlurEventData);
}
}
export function getLastFocusedViewOnPage(page: Page): View | null {
try {
const lastFocusedViewRef = page[lastFocusedViewOnPageKeyName] as WeakRef<View>;
if (!lastFocusedViewRef) {
return null;
}
const lastFocusedView = lastFocusedViewRef.get();
if (!lastFocusedView) {
return null;
}
if (!lastFocusedView.parent || lastFocusedView.page !== page) {
return null;
}
return lastFocusedView;
} catch {
// ignore
} finally {
delete page[lastFocusedViewOnPageKeyName];
}
return null;
}

View File

@ -0,0 +1,126 @@
import { applyCssClass, getRootView } from '../application';
import * as Application from '../application';
import type { View } from '../ui/core/view';
import { AccessibilityServiceEnabledObservable } from './accessibility-service';
import { FontScaleCategory, getCurrentFontScale, getFontScaleCategory, VALID_FONT_SCALES } from './font-scale';
// CSS-classes
const fontScaleExtraSmallCategoryClass = `a11y-fontscale-xs`;
const fontScaleMediumCategoryClass = `a11y-fontscale-m`;
const fontScaleExtraLargeCategoryClass = `a11y-fontscale-xl`;
const fontScaleCategoryClasses = [fontScaleExtraSmallCategoryClass, fontScaleMediumCategoryClass, fontScaleExtraLargeCategoryClass];
const a11yServiceEnabledClass = `a11y-service-enabled`;
const a11yServiceDisabledClass = `a11y-service-disabled`;
const a11yServiceClasses = [a11yServiceEnabledClass, a11yServiceDisabledClass];
let accessibilityServiceObservable: AccessibilityServiceEnabledObservable;
let fontScaleCssClasses: Map<number, string>;
let currentFontScaleClass = '';
let currentFontScaleCategory = '';
let currentA11YServiceClass = '';
function ensureClasses() {
if (accessibilityServiceObservable) {
return;
}
fontScaleCssClasses = new Map(VALID_FONT_SCALES.map((fs) => [fs, `a11y-fontscale-${Number(fs * 100).toFixed(0)}`]));
accessibilityServiceObservable = new AccessibilityServiceEnabledObservable();
}
function applyRootCssClass(cssClasses: string[], newCssClass: string): void {
const rootView = getRootView();
if (!rootView) {
return;
}
applyCssClass(rootView, cssClasses, newCssClass);
const rootModalViews = <Array<View>>rootView._getRootModalViews();
rootModalViews.forEach((rootModalView) => applyCssClass(rootModalView, cssClasses, newCssClass));
}
function applyFontScaleToRootViews(): void {
const rootView = getRootView();
if (!rootView) {
return;
}
const fontScale = getCurrentFontScale();
rootView.style._fontScale = fontScale;
const rootModalViews = <Array<View>>rootView._getRootModalViews();
rootModalViews.forEach((rootModalView) => (rootModalView.style._fontScale = fontScale));
}
export function initAccessibilityCssHelper(): void {
ensureClasses();
Application.on(Application.fontScaleChangedEvent, () => {
updateCurrentHelperClasses();
applyFontScaleToRootViews();
});
accessibilityServiceObservable.on(AccessibilityServiceEnabledObservable.propertyChangeEvent, updateCurrentHelperClasses);
}
/**
* Update the helper CSS-classes.
* Return true is any changes.
*/
function updateCurrentHelperClasses(): void {
const fontScale = getCurrentFontScale();
const fontScaleCategory = getFontScaleCategory();
const oldFontScaleClass = currentFontScaleClass;
if (fontScaleCssClasses.has(fontScale)) {
currentFontScaleClass = fontScaleCssClasses.get(fontScale);
} else {
currentFontScaleClass = fontScaleCssClasses.get(1);
}
if (oldFontScaleClass !== currentFontScaleClass) {
applyRootCssClass([...fontScaleCssClasses.values()], currentFontScaleClass);
}
const oldActiveFontScaleCategory = currentFontScaleCategory;
switch (fontScaleCategory) {
case FontScaleCategory.ExtraSmall: {
currentFontScaleCategory = fontScaleMediumCategoryClass;
break;
}
case FontScaleCategory.Medium: {
currentFontScaleCategory = fontScaleMediumCategoryClass;
break;
}
case FontScaleCategory.ExtraLarge: {
currentFontScaleCategory = fontScaleExtraLargeCategoryClass;
break;
}
default: {
currentFontScaleCategory = fontScaleMediumCategoryClass;
break;
}
}
if (oldActiveFontScaleCategory !== currentFontScaleCategory) {
applyRootCssClass(fontScaleCategoryClasses, currentFontScaleCategory);
}
const oldA11YStatusClass = currentA11YServiceClass;
if (accessibilityServiceObservable.accessibilityServiceEnabled) {
currentA11YServiceClass = a11yServiceEnabledClass;
} else {
currentA11YServiceClass = a11yServiceDisabledClass;
}
if (oldA11YStatusClass !== currentA11YServiceClass) {
applyRootCssClass(a11yServiceClasses, currentA11YServiceClass);
}
}

View File

@ -0,0 +1,121 @@
import { CssProperty, InheritedCssProperty, Property } from '../ui/core/properties';
import type { View } from '../ui/core/view';
import { booleanConverter } from '../ui/core/view-base';
import { Style } from '../ui/styling/style';
import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait } from './accessibility-types';
function makePropertyEnumConverter<T>(enumValues) {
return (value: string): T | null => {
if (!value || typeof value !== 'string') {
return null;
}
for (const [enumKey, enumValue] of Object.entries<T>(enumValues)) {
if (typeof enumKey !== 'string') {
continue;
}
if (enumKey === value || `${enumValue}`.toLowerCase() === `${value}`.toLowerCase()) {
return enumValue;
}
}
return null;
};
}
export const accessibilityEnabledProperty = new CssProperty<Style, boolean>({
name: 'accessible',
cssName: 'a11y-enabled',
valueConverter: booleanConverter,
});
accessibilityEnabledProperty.register(Style);
const accessibilityHiddenPropertyName = 'accessibilityHidden';
const accessibilityHiddenCssName = 'a11y-hidden';
export const accessibilityHiddenProperty = global.isIOS
? new InheritedCssProperty({
name: accessibilityHiddenPropertyName,
cssName: accessibilityHiddenCssName,
valueConverter: booleanConverter,
})
: new CssProperty({
name: accessibilityHiddenPropertyName,
cssName: accessibilityHiddenCssName,
valueConverter: booleanConverter,
});
accessibilityHiddenProperty.register(Style);
export const accessibilityIdentifierProperty = new Property<View, string>({
name: 'accessibilityIdentifier',
});
export const accessibilityRoleProperty = new CssProperty<Style, AccessibilityRole>({
name: 'accessibilityRole',
cssName: 'a11y-role',
valueConverter: makePropertyEnumConverter<AccessibilityRole>(AccessibilityRole),
});
accessibilityRoleProperty.register(Style);
export const accessibilityStateProperty = new CssProperty<Style, AccessibilityState>({
name: 'accessibilityState',
cssName: 'a11y-state',
valueConverter: makePropertyEnumConverter<AccessibilityState>(AccessibilityState),
});
accessibilityStateProperty.register(Style);
export const accessibilityLabelProperty = new Property<View, string>({
name: 'accessibilityLabel',
});
export const accessibilityValueProperty = new Property<View, string>({
name: 'accessibilityValue',
});
export const accessibilityHintProperty = new Property<View, string>({
name: 'accessibilityHint',
});
export const accessibilityLiveRegionProperty = new CssProperty<Style, AccessibilityLiveRegion>({
name: 'accessibilityLiveRegion',
cssName: 'a11y-live-region',
defaultValue: AccessibilityLiveRegion.None,
valueConverter: makePropertyEnumConverter<AccessibilityLiveRegion>(AccessibilityLiveRegion),
});
accessibilityLiveRegionProperty.register(Style);
export const accessibilityTraitsProperty = new Property<View, AccessibilityTrait | AccessibilityTrait[]>({
name: 'accessibilityTraits',
});
export const accessibilityLanguageProperty = new CssProperty<Style, string>({
name: 'accessibilityLanguage',
cssName: 'a11y-lang',
});
accessibilityLanguageProperty.register(Style);
export const accessibilityMediaSessionProperty = new CssProperty({
name: 'accessibilityMediaSession',
cssName: 'a11y-media-session',
});
accessibilityMediaSessionProperty.register(Style);
/**
* Represents the observable property backing the accessibilityStep property.
*/
export const accessibilityStepProperty = new CssProperty<Style, number>({
name: 'accessibilityStep',
cssName: 'a11y-step',
defaultValue: 10,
valueConverter: (v): number => {
const step = parseFloat(v);
if (isNaN(step) || step <= 0) {
return 10;
}
return step;
},
});
accessibilityStepProperty.register(Style);

View File

@ -0,0 +1,35 @@
import { Observable } from '../data/observable';
export class SharedA11YObservable extends Observable {
accessibilityServiceEnabled?: boolean;
}
export const AccessibilityServiceEnabledPropName = 'accessibilityServiceEnabled';
export class CommonA11YServiceEnabledObservable extends SharedA11YObservable {
constructor(sharedA11YObservable: SharedA11YObservable) {
super();
const ref = new WeakRef(this);
let lastValue: boolean;
function callback() {
const self = ref?.get();
if (!self) {
sharedA11YObservable.off(Observable.propertyChangeEvent, callback);
return;
}
const newValue = !!sharedA11YObservable.accessibilityServiceEnabled;
if (newValue !== lastValue) {
self.set(AccessibilityServiceEnabledPropName, newValue);
lastValue = newValue;
}
}
sharedA11YObservable.on(Observable.propertyChangeEvent, callback);
this.set(AccessibilityServiceEnabledPropName, !!sharedA11YObservable.accessibilityServiceEnabled);
}
}

View File

@ -0,0 +1,130 @@
import * as Application from '../application';
import { Observable } from '../data/observable';
import { Trace } from '../trace';
import * as Utils from '../utils';
import { CommonA11YServiceEnabledObservable, SharedA11YObservable } from './accessibility-service-common';
export function getAndroidAccessibilityManager(): android.view.accessibility.AccessibilityManager | null {
const context = Utils.ad.getApplicationContext() as android.content.Context;
if (!context) {
return null;
}
return context.getSystemService(android.content.Context.ACCESSIBILITY_SERVICE) as android.view.accessibility.AccessibilityManager;
}
const accessibilityStateEnabledPropName = 'accessibilityStateEnabled';
const touchExplorationStateEnabledPropName = 'touchExplorationStateEnabled';
class AndroidSharedA11YObservable extends SharedA11YObservable {
[accessibilityStateEnabledPropName]: boolean;
[touchExplorationStateEnabledPropName]: boolean;
get accessibilityServiceEnabled(): boolean {
return !!this[accessibilityStateEnabledPropName] && !!this[touchExplorationStateEnabledPropName];
}
set accessibilityServiceEnabled(v) {
return;
}
}
let accessibilityStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.AccessibilityStateChangeListener;
let touchExplorationStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener;
let sharedA11YObservable: AndroidSharedA11YObservable;
function updateAccessibilityState(): void {
const accessibilityManager = getAndroidAccessibilityManager();
if (!accessibilityManager) {
sharedA11YObservable.set(accessibilityStateEnabledPropName, false);
sharedA11YObservable.set(touchExplorationStateEnabledPropName, false);
return;
}
sharedA11YObservable.set(accessibilityStateEnabledPropName, !!accessibilityManager.isEnabled());
sharedA11YObservable.set(touchExplorationStateEnabledPropName, !!accessibilityManager.isTouchExplorationEnabled());
}
function ensureStateListener(): SharedA11YObservable {
if (sharedA11YObservable) {
return sharedA11YObservable;
}
const accessibilityManager = getAndroidAccessibilityManager();
sharedA11YObservable = new AndroidSharedA11YObservable();
if (!accessibilityManager) {
sharedA11YObservable.set(accessibilityStateEnabledPropName, false);
sharedA11YObservable.set(touchExplorationStateEnabledPropName, false);
return sharedA11YObservable;
}
accessibilityStateChangeListener = new android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener({
onAccessibilityStateChanged(enabled) {
updateAccessibilityState();
if (Trace.isEnabled()) {
Trace.write(`AccessibilityStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility);
}
},
});
touchExplorationStateChangeListener = new androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener({
onTouchExplorationStateChanged(enabled) {
updateAccessibilityState();
if (Trace.isEnabled()) {
Trace.write(`TouchExplorationStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility);
}
},
});
accessibilityManager.addAccessibilityStateChangeListener(accessibilityStateChangeListener);
androidx.core.view.accessibility.AccessibilityManagerCompat.addTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener);
updateAccessibilityState();
Application.on(Application.resumeEvent, updateAccessibilityState);
return sharedA11YObservable;
}
export function isAccessibilityServiceEnabled(): boolean {
return ensureStateListener().accessibilityServiceEnabled;
}
Application.on(Application.exitEvent, (args: Application.ApplicationEventData) => {
const activity = args.android as android.app.Activity;
if (activity && !activity.isFinishing()) {
return;
}
const accessibilityManager = getAndroidAccessibilityManager();
if (accessibilityManager) {
if (accessibilityStateChangeListener) {
accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeListener);
}
if (touchExplorationStateChangeListener) {
androidx.core.view.accessibility.AccessibilityManagerCompat.removeTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener);
}
}
accessibilityStateChangeListener = null;
touchExplorationStateChangeListener = null;
if (sharedA11YObservable) {
sharedA11YObservable.removeEventListener(Observable.propertyChangeEvent);
sharedA11YObservable = null;
}
Application.off(Application.resumeEvent, updateAccessibilityState);
});
export class AccessibilityServiceEnabledObservable extends CommonA11YServiceEnabledObservable {
constructor() {
super(ensureStateListener());
}
}

View File

@ -0,0 +1,10 @@
import { Observable } from '../data/observable';
export class AccessibilityServiceEnabledObservable extends Observable {
public readonly accessibilityServiceEnabled?: boolean;
}
/**
* Get the Android platform's AccessibilityManager.
*/
export function getAndroidAccessibilityManager(): /* android.view.accessibility.AccessibilityManager */ any | null;

View File

@ -0,0 +1,75 @@
import * as Application from '../application';
import { Observable } from '../data/observable';
import { Trace } from '../trace';
import { AccessibilityServiceEnabledPropName, CommonA11YServiceEnabledObservable, SharedA11YObservable } from './accessibility-service-common';
export function isAccessibilityServiceEnabled(): boolean {
return getSharedA11YObservable().accessibilityServiceEnabled;
}
export function getAndroidAccessibilityManager(): null {
return null;
}
let sharedA11YObservable: SharedA11YObservable;
let nativeObserver;
function getSharedA11YObservable(): SharedA11YObservable {
if (sharedA11YObservable) {
return sharedA11YObservable;
}
sharedA11YObservable = new SharedA11YObservable();
let isVoiceOverRunning: () => boolean;
if (typeof UIAccessibilityIsVoiceOverRunning === 'function') {
isVoiceOverRunning = UIAccessibilityIsVoiceOverRunning;
} else {
if (typeof UIAccessibilityIsVoiceOverRunning !== 'function') {
Trace.write(`UIAccessibilityIsVoiceOverRunning() - is not a function`, Trace.categories.Accessibility, Trace.messageType.error);
isVoiceOverRunning = () => false;
}
}
sharedA11YObservable.set(AccessibilityServiceEnabledPropName, isVoiceOverRunning());
let voiceOverStatusChangedNotificationName: string | null = null;
if (typeof UIAccessibilityVoiceOverStatusDidChangeNotification !== 'undefined') {
// iOS 11+
voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusDidChangeNotification;
} else if (typeof UIAccessibilityVoiceOverStatusChanged !== 'undefined') {
// iOS <11
voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusChanged;
}
if (voiceOverStatusChangedNotificationName) {
nativeObserver = Application.ios.addNotificationObserver(voiceOverStatusChangedNotificationName, () => {
sharedA11YObservable?.set(AccessibilityServiceEnabledPropName, isVoiceOverRunning());
});
Application.on(Application.exitEvent, () => {
if (nativeObserver) {
Application.ios.removeNotificationObserver(nativeObserver, voiceOverStatusChangedNotificationName);
}
nativeObserver = null;
if (sharedA11YObservable) {
sharedA11YObservable.removeEventListener(Observable.propertyChangeEvent);
sharedA11YObservable = null;
}
});
}
Application.on(Application.resumeEvent, () => sharedA11YObservable.set(AccessibilityServiceEnabledPropName, isVoiceOverRunning()));
return sharedA11YObservable;
}
export class AccessibilityServiceEnabledObservable extends CommonA11YServiceEnabledObservable {
constructor() {
super(getSharedA11YObservable());
}
}

View File

@ -0,0 +1,326 @@
import type { EventData } from '../data/observable';
import type { View } from '../ui/core/view';
export enum AccessibilityTrait {
/**
* The element has no traits.
*/
None = 'none',
/**
* The element should be treated as a button.
*/
Button = 'button',
/**
* The element should be treated as a link.
*/
Link = 'link',
/**
* The element should be treated as a search field.
*/
SearchField = 'search',
/**
* The element should be treated as an image.
*/
Image = 'image',
/**
* The element is currently selected.
*/
Selected = 'selected',
/**
* The element plays its own sound when activated.
*/
PlaysSound = 'plays',
/**
* The element behaves as a keyboard key.
*/
KeyboardKey = 'key',
/**
* The element should be treated as static text that cannot change.
*/
StaticText = 'text',
/**
* The element provides summary information when the application starts.
*/
SummaryElement = 'summary',
/**
* The element is not enabled and does not respond to user interaction.
*/
NotEnabled = 'disabled',
/**
* The element frequently updates its label or value.
*/
UpdatesFrequently = 'frequentUpdates',
/**
* The element starts a media session when it is activated.
*/
StartsMediaSession = 'startsMedia',
/**
* The element allows continuous adjustment through a range of values.
*/
Adjustable = 'adjustable',
/**
* The element allows direct touch interaction for VoiceOver users.
*/
AllowsDirectInteraction = 'allowsDirectInteraction',
/**
* The element should cause an automatic page turn when VoiceOver finishes reading the text within it.
* Note: Requires custom view with accessibilityScroll(...)
*/
CausesPageTurn = 'pageTurn',
/**
* The element is a header that divides content into sections, such as the title of a navigation bar.
*/
Header = 'header',
}
export enum AccessibilityRole {
/**
* The element has no traits.
*/
None = 'none',
/**
* The element should be treated as a button.
*/
Button = 'button',
/**
* The element should be treated as a link.
*/
Link = 'link',
/**
* The element should be treated as a search field.
*/
Search = 'search',
/**
* The element should be treated as an image.
*/
Image = 'image',
/**
* The element should be treated as a image button.
*/
ImageButton = 'imageButton',
/**
* The element behaves as a keyboard key.
*/
KeyboardKey = 'keyboardKey',
/**
* The element should be treated as static text that cannot change.
*/
StaticText = 'textField',
/**
* The element allows continuous adjustment through a range of values.
*/
Adjustable = 'adjustable',
/**
* The element provides summary information when the application starts.
*/
Summary = 'summary',
/**
* The element is a header that divides content into sections, such as the title of a navigation bar.
*/
Header = 'header',
/**
* The element behaves like a Checkbox
*/
Checkbox = 'checkbox',
/**
* The element behaves like a ProgressBar
*/
ProgressBar = 'progressBar',
/**
* The element behaves like a RadioButton
*/
RadioButton = 'radioButton',
/**
* The element behaves like a SpinButton
*/
SpinButton = 'spinButton',
/**
* The element behaves like a switch
*/
Switch = 'switch',
}
export enum AccessibilityState {
Selected = 'selected',
Checked = 'checked',
Unchecked = 'unchecked',
Disabled = 'disabled',
}
export enum AccessibilityLiveRegion {
None = 'none',
Polite = 'polite',
Assertive = 'assertive',
}
export interface AccessibilityFocusEventData extends EventData {
object: View;
}
export type AccessibilityBlurEventData = AccessibilityFocusEventData;
export interface AccessibilityFocusChangedEventData extends AccessibilityFocusEventData {
value: boolean;
}
export enum IOSPostAccessibilityNotificationType {
Announcement = 'announcement',
Screen = 'screen',
Layout = 'layout',
}
export enum AndroidAccessibilityEvent {
/**
* Invalid selection/focus position.
*/
INVALID_POSITION = 'invalid_position',
/**
* Maximum length of the text fields.
*/
MAX_TEXT_LENGTH = 'max_text_length',
/**
* Represents the event of clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc.
*/
VIEW_CLICKED = 'view_clicked',
/**
* Represents the event of long clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc.
*/
VIEW_LONG_CLICKED = 'view_long_clicked',
/**
* Represents the event of selecting an item usually in the context of an android.widget.AdapterView.
*/
VIEW_SELECTED = 'view_selected',
/**
* Represents the event of setting input focus of a android.view.View.
*/
VIEW_FOCUSED = 'view_focused',
/**
* Represents the event of changing the text of an android.widget.EditText.
*/
VIEW_TEXT_CHANGED = 'view_text_changed',
/**
* Represents the event of opening a android.widget.PopupWindow, android.view.Menu, android.app.Dialog, etc.
*/
WINDOW_STATE_CHANGED = 'window_state_changed',
/**
* Represents the event showing a android.app.Notification.
*/
NOTIFICATION_STATE_CHANGED = 'notification_state_changed',
/**
* Represents the event of a hover enter over a android.view.View.
*/
VIEW_HOVER_ENTER = 'view_hover_enter',
/**
* Represents the event of a hover exit over a android.view.View.
*/
VIEW_HOVER_EXIT = 'view_hover_exit',
/**
* Represents the event of starting a touch exploration gesture.
*/
TOUCH_EXPLORATION_GESTURE_START = 'touch_exploration_gesture_start',
/**
* Represents the event of ending a touch exploration gesture.
*/
TOUCH_EXPLORATION_GESTURE_END = 'touch_exploration_gesture_end',
/**
* Represents the event of changing the content of a window and more specifically the sub-tree rooted at the event's source.
*/
WINDOW_CONTENT_CHANGED = 'window_content_changed',
/**
* Represents the event of scrolling a view.
*/
VIEW_SCROLLED = 'view_scrolled',
/**
* Represents the event of changing the selection in an android.widget.EditText.
*/
VIEW_TEXT_SELECTION_CHANGED = 'view_text_selection_changed',
/**
* Represents the event of an application making an announcement.
*/
ANNOUNCEMENT = 'announcement',
/**
* Represents the event of gaining accessibility focus.
*/
VIEW_ACCESSIBILITY_FOCUSED = 'view_accessibility_focused',
/**
* Represents the event of clearing accessibility focus.
*/
VIEW_ACCESSIBILITY_FOCUS_CLEARED = 'view_accessibility_focus_cleared',
/**
* Represents the event of traversing the text of a view at a given movement granularity.
*/
VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY = 'view_text_traversed_at_movement_granularity',
/**
* Represents the event of beginning gesture detection.
*/
GESTURE_DETECTION_START = 'gesture_detection_start',
/**
* Represents the event of ending gesture detection.
*/
GESTURE_DETECTION_END = 'gesture_detection_end',
/**
* Represents the event of the user starting to touch the screen.
*/
TOUCH_INTERACTION_START = 'touch_interaction_start',
/**
* Represents the event of the user ending to touch the screen.
*/
TOUCH_INTERACTION_END = 'touch_interaction_end',
/**
* Mask for AccessibilityEvent all types.
*/
ALL_MASK = 'all',
}

View File

@ -0,0 +1,15 @@
export const VALID_FONT_SCALES = global.isIOS // iOS supports a wider number of font scales than Android does.
? [0.5, 0.7, 0.85, 1, 1.15, 1.3, 1.5, 2, 2.5, 3, 3.5, 4]
: [0.85, 1, 1.15, 1.3];
export function getClosestValidFontScale(fontScale: number): number {
fontScale = Number(fontScale) || 1;
return VALID_FONT_SCALES.sort((a, b) => Math.abs(fontScale - a) - Math.abs(fontScale - b)).shift();
}
export enum FontScaleCategory {
ExtraSmall = 'extra-small',
Medium = 'medium',
ExtraLarge = 'extra-large',
}

View File

@ -0,0 +1,66 @@
import * as Application from '../application';
import { FontScaleCategory, getClosestValidFontScale } from './font-scale-common';
export * from './font-scale-common';
let currentFontScale: number = null;
function fontScaleChanged(origFontScale: number) {
const oldValue = currentFontScale;
currentFontScale = getClosestValidFontScale(origFontScale);
if (oldValue !== currentFontScale) {
Application.notify({
eventName: Application.fontScaleChangedEvent,
object: Application,
});
}
}
export function getCurrentFontScale(): number {
setupConfigListener();
return currentFontScale;
}
export function getFontScaleCategory(): FontScaleCategory {
return FontScaleCategory.Medium;
}
function useAndroidFontScale() {
fontScaleChanged(Number(Application.android.context.getResources().getConfiguration().fontScale));
}
let configChangedCallback: android.content.ComponentCallbacks2;
function setupConfigListener() {
if (configChangedCallback) {
return;
}
Application.off(Application.launchEvent, setupConfigListener);
const context = Application.android?.context as android.content.Context;
if (!context) {
Application.on(Application.launchEvent, setupConfigListener);
return;
}
useAndroidFontScale();
configChangedCallback = new android.content.ComponentCallbacks2({
onLowMemory() {
// Dummy
},
onTrimMemory() {
// Dummy
},
onConfigurationChanged(newConfig: android.content.res.Configuration) {
fontScaleChanged(Number(newConfig.fontScale));
},
});
context.registerComponentCallbacks(configChangedCallback);
Application.on(Application.resumeEvent, useAndroidFontScale);
}
export function initAccessibilityFontScale(): void {
setupConfigListener();
}

View File

@ -0,0 +1,9 @@
export const VALID_FONT_SCALES: number[];
export function getCurrentFontScale(): number;
export enum FontScaleCategory {
ExtraSmall = 'extra-small',
Medium = 'medium',
ExtraLarge = 'extra-large',
}
export function getFontScaleCategory(): FontScaleCategory;
export function initAccessibilityFontScale(): void;

View File

@ -0,0 +1,109 @@
import * as Application from '../application';
import { FontScaleCategory, getClosestValidFontScale } from './font-scale-common';
export * from './font-scale-common';
let currentFontScale: number = null;
function fontScaleChanged(origFontScale: number) {
const oldValue = currentFontScale;
currentFontScale = getClosestValidFontScale(origFontScale);
if (oldValue !== currentFontScale) {
Application.notify({
eventName: Application.fontScaleChangedEvent,
object: Application,
});
}
}
export function getCurrentFontScale(): number {
setupConfigListener();
return currentFontScale;
}
export function getFontScaleCategory(): FontScaleCategory {
if (currentFontScale < 0.85) {
return FontScaleCategory.ExtraSmall;
}
if (currentFontScale > 1.5) {
return FontScaleCategory.ExtraLarge;
}
return FontScaleCategory.Medium;
}
const sizeMap = new Map<string, number>([
[UIContentSizeCategoryExtraSmall, 0.5],
[UIContentSizeCategorySmall, 0.7],
[UIContentSizeCategoryMedium, 0.85],
[UIContentSizeCategoryLarge, 1],
[UIContentSizeCategoryExtraLarge, 1.15],
[UIContentSizeCategoryExtraExtraLarge, 1.3],
[UIContentSizeCategoryExtraExtraExtraLarge, 1.5],
[UIContentSizeCategoryAccessibilityMedium, 2],
[UIContentSizeCategoryAccessibilityLarge, 2.5],
[UIContentSizeCategoryAccessibilityExtraLarge, 3],
[UIContentSizeCategoryAccessibilityExtraExtraLarge, 3.5],
[UIContentSizeCategoryAccessibilityExtraExtraExtraLarge, 4],
]);
function contentSizeUpdated(fontSize: string) {
if (sizeMap.has(fontSize)) {
fontScaleChanged(sizeMap.get(fontSize));
return;
}
fontScaleChanged(1);
}
function useIOSFontScale() {
if (Application.ios.nativeApp) {
contentSizeUpdated(Application.ios.nativeApp.preferredContentSizeCategory);
} else {
fontScaleChanged(1);
}
}
let fontSizeObserver;
function setupConfigListener(attempt = 0) {
if (fontSizeObserver) {
return;
}
if (!Application.ios.nativeApp) {
if (attempt > 100) {
fontScaleChanged(1);
return;
}
// Couldn't get launchEvent to trigger.
setTimeout(() => setupConfigListener(attempt + 1), 1);
return;
}
fontSizeObserver = Application.ios.addNotificationObserver(UIContentSizeCategoryDidChangeNotification, (args) => {
const fontSize = args.userInfo.valueForKey(UIContentSizeCategoryNewValueKey);
contentSizeUpdated(fontSize);
});
Application.on(Application.exitEvent, () => {
if (fontSizeObserver) {
Application.ios.removeNotificationObserver(fontSizeObserver, UIContentSizeCategoryDidChangeNotification);
fontSizeObserver = null;
}
Application.off(Application.resumeEvent, useIOSFontScale);
});
Application.on(Application.resumeEvent, useIOSFontScale);
useIOSFontScale();
}
export function initAccessibilityFontScale(): void {
setupConfigListener();
}

View File

@ -0,0 +1,667 @@
import * as Application from '../application';
import { Trace } from '../trace';
import type { View } from '../ui/core/view';
import { GestureTypes } from '../ui/gestures';
import { notifyAccessibilityFocusState } from './accessibility-common';
import { getAndroidAccessibilityManager } from './accessibility-service';
import { AccessibilityRole, AccessibilityState, AndroidAccessibilityEvent } from './accessibility-types';
export * from './accessibility-common';
export * from './accessibility-types';
export * from './font-scale';
let clickableRolesMap = new Set<string>();
let lastFocusedView: WeakRef<View>;
function accessibilityEventHelper(view: View, eventType: number) {
const eventName = accessibilityEventTypeMap.get(eventType);
if (!isAccessibilityServiceEnabled()) {
if (Trace.isEnabled()) {
Trace.write(`accessibilityEventHelper: Service not active`, Trace.categories.Accessibility);
}
return;
}
if (!eventName) {
Trace.write(`accessibilityEventHelper: unknown eventType: ${eventType}`, Trace.categories.Accessibility, Trace.messageType.error);
return;
}
if (!view) {
if (Trace.isEnabled()) {
Trace.write(`accessibilityEventHelper: no owner: ${eventName}`, Trace.categories.Accessibility);
}
return;
}
const androidView = view.nativeViewProtected as android.view.View;
if (!androidView) {
if (Trace.isEnabled()) {
Trace.write(`accessibilityEventHelper: no nativeView`, Trace.categories.Accessibility);
}
return;
}
switch (eventType) {
case android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED: {
/**
* Android API >= 26 handles accessibility tap-events by converting them to TYPE_VIEW_CLICKED
* These aren't triggered for custom tap events in NativeScript.
*/
if (android.os.Build.VERSION.SDK_INT >= 26) {
// Find all tap gestures and trigger them.
for (const tapGesture of view.getGestureObservers(GestureTypes.tap) ?? []) {
tapGesture.callback({
android: view.android,
eventName: 'tap',
ios: null,
object: view,
type: GestureTypes.tap,
view: view,
});
}
}
return;
}
case android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: {
const lastView = lastFocusedView?.get();
if (lastView && view !== lastView) {
const lastAndroidView = lastView.nativeViewProtected as android.view.View;
if (lastAndroidView) {
lastAndroidView.clearFocus();
lastFocusedView = null;
notifyAccessibilityFocusState(lastView, false, true);
}
}
lastFocusedView = new WeakRef(view);
notifyAccessibilityFocusState(view, true, false);
return;
}
case android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: {
const lastView = lastFocusedView?.get();
if (lastView && view === lastView) {
lastFocusedView = null;
androidView.clearFocus();
}
notifyAccessibilityFocusState(view, false, true);
return;
}
}
}
let TNSAccessibilityDelegate: android.view.View.androidviewViewAccessibilityDelegate;
const androidViewToTNSView = new WeakMap<android.view.View, WeakRef<View>>();
let accessibilityEventMap: Map<AndroidAccessibilityEvent, number>;
let accessibilityEventTypeMap: Map<number, string>;
function ensureNativeClasses() {
if (TNSAccessibilityDelegate) {
return;
}
// WORKAROUND: Typing refers to android.view.View.androidviewViewAccessibilityDelegate but it is called android.view.View.AccessibilityDelegate at runtime
const AccessibilityDelegate: typeof android.view.View.androidviewViewAccessibilityDelegate = android.view.View['AccessibilityDelegate'];
const RoleTypeMap = new Map<AccessibilityRole, string>([
[AccessibilityRole.Button, android.widget.Button.class.getName()],
[AccessibilityRole.Search, android.widget.EditText.class.getName()],
[AccessibilityRole.Image, android.widget.ImageView.class.getName()],
[AccessibilityRole.ImageButton, android.widget.ImageButton.class.getName()],
[AccessibilityRole.KeyboardKey, android.inputmethodservice.Keyboard.Key.class.getName()],
[AccessibilityRole.StaticText, android.widget.TextView.class.getName()],
[AccessibilityRole.Adjustable, android.widget.SeekBar.class.getName()],
[AccessibilityRole.Checkbox, android.widget.CheckBox.class.getName()],
[AccessibilityRole.RadioButton, android.widget.RadioButton.class.getName()],
[AccessibilityRole.SpinButton, android.widget.Spinner.class.getName()],
[AccessibilityRole.Switch, android.widget.Switch.class.getName()],
[AccessibilityRole.ProgressBar, android.widget.ProgressBar.class.getName()],
]);
clickableRolesMap = new Set<string>([AccessibilityRole.Button, AccessibilityRole.ImageButton]);
const ignoreRoleTypesForTrace = new Set([AccessibilityRole.Header, AccessibilityRole.Link, AccessibilityRole.None, AccessibilityRole.Summary]);
@NativeClass()
class TNSAccessibilityDelegateImpl extends AccessibilityDelegate {
constructor() {
super();
return global.__native(this);
}
private getTnsView(androidView: android.view.View) {
const view = androidViewToTNSView.get(androidView)?.get();
if (!view) {
androidViewToTNSView.delete(androidView);
return null;
}
return view;
}
public onInitializeAccessibilityNodeInfo(host: android.view.View, info: android.view.accessibility.AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(host, info);
const view = this.getTnsView(host);
if (!view) {
if (Trace.isEnabled()) {
Trace.write(`onInitializeAccessibilityNodeInfo ${host} ${info} no tns-view`, Trace.categories.Accessibility);
}
return;
}
const accessibilityRole = view.accessibilityRole;
if (accessibilityRole) {
const androidClassName = RoleTypeMap.get(accessibilityRole);
if (androidClassName) {
const oldClassName = info.getClassName() || (android.os.Build.VERSION.SDK_INT >= 28 && host.getAccessibilityClassName()) || null;
info.setClassName(androidClassName);
if (Trace.isEnabled()) {
Trace.write(`${view}.accessibilityRole = "${accessibilityRole}" is mapped to "${androidClassName}" (was ${oldClassName}). ${info.getClassName()}`, Trace.categories.Accessibility);
}
} else if (!ignoreRoleTypesForTrace.has(accessibilityRole)) {
if (Trace.isEnabled()) {
Trace.write(`${view}.accessibilityRole = "${accessibilityRole}" is unknown`, Trace.categories.Accessibility);
}
}
if (clickableRolesMap.has(accessibilityRole)) {
if (Trace.isEnabled()) {
Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set clickable role=${accessibilityRole}`, Trace.categories.Accessibility);
}
info.setClickable(true);
}
if (android.os.Build.VERSION.SDK_INT >= 28) {
if (accessibilityRole === AccessibilityRole.Header) {
if (Trace.isEnabled()) {
Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set heading role=${accessibilityRole}`, Trace.categories.Accessibility);
}
info.setHeading(true);
} else if (host.isAccessibilityHeading()) {
if (Trace.isEnabled()) {
Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set heading from host`, Trace.categories.Accessibility);
}
info.setHeading(true);
} else {
if (Trace.isEnabled()) {
Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set not heading`, Trace.categories.Accessibility);
}
info.setHeading(false);
}
}
switch (accessibilityRole) {
case AccessibilityRole.Switch:
case AccessibilityRole.RadioButton:
case AccessibilityRole.Checkbox: {
if (Trace.isEnabled()) {
Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set checkable and check=${view.accessibilityState === AccessibilityState.Checked}`, Trace.categories.Accessibility);
}
info.setCheckable(true);
info.setChecked(view.accessibilityState === AccessibilityState.Checked);
break;
}
default: {
if (Trace.isEnabled()) {
Trace.write(`onInitializeAccessibilityNodeInfo ${view} - set enabled=${view.accessibilityState !== AccessibilityState.Disabled} and selected=${view.accessibilityState === AccessibilityState.Selected}`, Trace.categories.Accessibility);
}
info.setEnabled(view.accessibilityState !== AccessibilityState.Disabled);
info.setSelected(view.accessibilityState === AccessibilityState.Selected);
break;
}
}
}
if (view.accessible === true) {
info.setFocusable(true);
}
}
public sendAccessibilityEvent(host: android.view.ViewGroup, eventType: number) {
super.sendAccessibilityEvent(host, eventType);
const view = this.getTnsView(host);
if (!view) {
console.log(`skip - ${host} - ${accessibilityEventTypeMap.get(eventType)}`);
return;
}
try {
accessibilityEventHelper(view, eventType);
} catch (err) {
console.error(err);
}
}
}
TNSAccessibilityDelegate = new TNSAccessibilityDelegateImpl();
accessibilityEventMap = new Map<AndroidAccessibilityEvent, number>([
/**
* Invalid selection/focus position.
*/
[AndroidAccessibilityEvent.INVALID_POSITION, android.view.accessibility.AccessibilityEvent.INVALID_POSITION],
/**
* Maximum length of the text fields.
*/
[AndroidAccessibilityEvent.MAX_TEXT_LENGTH, android.view.accessibility.AccessibilityEvent.MAX_TEXT_LENGTH],
/**
* Represents the event of clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc.
*/
[AndroidAccessibilityEvent.VIEW_CLICKED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED],
/**
* Represents the event of long clicking on a android.view.View like android.widget.Button, android.widget.CompoundButton, etc.
*/
[AndroidAccessibilityEvent.VIEW_LONG_CLICKED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_LONG_CLICKED],
/**
* Represents the event of selecting an item usually in the context of an android.widget.AdapterView.
*/
[AndroidAccessibilityEvent.VIEW_SELECTED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SELECTED],
/**
* Represents the event of setting input focus of a android.view.View.
*/
[AndroidAccessibilityEvent.VIEW_FOCUSED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED],
/**
* Represents the event of changing the text of an android.widget.EditText.
*/
[AndroidAccessibilityEvent.VIEW_TEXT_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED],
/**
* Represents the event of opening a android.widget.PopupWindow, android.view.Menu, android.app.Dialog, etc.
*/
[AndroidAccessibilityEvent.WINDOW_STATE_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED],
/**
* Represents the event showing a android.app.Notification.
*/
[AndroidAccessibilityEvent.NOTIFICATION_STATE_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED],
/**
* Represents the event of a hover enter over a android.view.View.
*/
[AndroidAccessibilityEvent.VIEW_HOVER_ENTER, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_HOVER_ENTER],
/**
* Represents the event of a hover exit over a android.view.View.
*/
[AndroidAccessibilityEvent.VIEW_HOVER_EXIT, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_HOVER_EXIT],
/**
* Represents the event of starting a touch exploration gesture.
*/
[AndroidAccessibilityEvent.TOUCH_EXPLORATION_GESTURE_START, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_START],
/**
* Represents the event of ending a touch exploration gesture.
*/
[AndroidAccessibilityEvent.TOUCH_EXPLORATION_GESTURE_END, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END],
/**
* Represents the event of changing the content of a window and more specifically the sub-tree rooted at the event's source.
*/
[AndroidAccessibilityEvent.WINDOW_CONTENT_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED],
/**
* Represents the event of scrolling a view.
*/
[AndroidAccessibilityEvent.VIEW_SCROLLED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SCROLLED],
/**
* Represents the event of changing the selection in an android.widget.EditText.
*/
[AndroidAccessibilityEvent.VIEW_TEXT_SELECTION_CHANGED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED],
/**
* Represents the event of an application making an announcement.
*/
[AndroidAccessibilityEvent.ANNOUNCEMENT, android.view.accessibility.AccessibilityEvent.TYPE_ANNOUNCEMENT],
/**
* Represents the event of gaining accessibility focus.
*/
[AndroidAccessibilityEvent.VIEW_ACCESSIBILITY_FOCUSED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED],
/**
* Represents the event of clearing accessibility focus.
*/
[AndroidAccessibilityEvent.VIEW_ACCESSIBILITY_FOCUS_CLEARED, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED],
/**
* Represents the event of traversing the text of a view at a given movement granularity.
*/
[AndroidAccessibilityEvent.VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY],
/**
* Represents the event of beginning gesture detection.
*/
[AndroidAccessibilityEvent.GESTURE_DETECTION_START, android.view.accessibility.AccessibilityEvent.TYPE_GESTURE_DETECTION_START],
/**
* Represents the event of ending gesture detection.
*/
[AndroidAccessibilityEvent.GESTURE_DETECTION_END, android.view.accessibility.AccessibilityEvent.TYPE_GESTURE_DETECTION_END],
/**
* Represents the event of the user starting to touch the screen.
*/
[AndroidAccessibilityEvent.TOUCH_INTERACTION_START, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_INTERACTION_START],
/**
* Represents the event of the user ending to touch the screen.
*/
[AndroidAccessibilityEvent.TOUCH_INTERACTION_END, android.view.accessibility.AccessibilityEvent.TYPE_TOUCH_INTERACTION_END],
/**
* Mask for AccessibilityEvent all types.
*/
[AndroidAccessibilityEvent.ALL_MASK, android.view.accessibility.AccessibilityEvent.TYPES_ALL_MASK],
]);
accessibilityEventTypeMap = new Map([...accessibilityEventMap].map(([k, v]) => [v, k]));
}
let accessibilityStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.AccessibilityStateChangeListener;
let touchExplorationStateChangeListener: androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener;
function updateAccessibilityServiceState() {
const accessibilityManager = getAndroidAccessibilityManager();
if (!accessibilityManager) {
return;
}
accessibilityServiceEnabled = !!accessibilityManager.isEnabled() && !!accessibilityManager.isTouchExplorationEnabled();
}
let accessibilityServiceEnabled: boolean;
export function isAccessibilityServiceEnabled(): boolean {
if (typeof accessibilityServiceEnabled === 'boolean') {
return accessibilityServiceEnabled;
}
const accessibilityManager = getAndroidAccessibilityManager();
accessibilityStateChangeListener = new androidx.core.view.accessibility.AccessibilityManagerCompat.AccessibilityStateChangeListener({
onAccessibilityStateChanged(enabled) {
updateAccessibilityServiceState();
if (Trace.isEnabled()) {
Trace.write(`AccessibilityStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility);
}
},
});
touchExplorationStateChangeListener = new androidx.core.view.accessibility.AccessibilityManagerCompat.TouchExplorationStateChangeListener({
onTouchExplorationStateChanged(enabled) {
updateAccessibilityServiceState();
if (Trace.isEnabled()) {
Trace.write(`TouchExplorationStateChangeListener state changed to: ${!!enabled}`, Trace.categories.Accessibility);
}
},
});
androidx.core.view.accessibility.AccessibilityManagerCompat.addAccessibilityStateChangeListener(accessibilityManager, accessibilityStateChangeListener);
androidx.core.view.accessibility.AccessibilityManagerCompat.addTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener);
updateAccessibilityServiceState();
Application.on(Application.exitEvent, (args: Application.ApplicationEventData) => {
const activity = args.android as android.app.Activity;
if (activity && !activity.isFinishing()) {
return;
}
const accessibilityManager = getAndroidAccessibilityManager();
if (accessibilityManager) {
if (accessibilityStateChangeListener) {
androidx.core.view.accessibility.AccessibilityManagerCompat.removeAccessibilityStateChangeListener(accessibilityManager, accessibilityStateChangeListener);
}
if (touchExplorationStateChangeListener) {
androidx.core.view.accessibility.AccessibilityManagerCompat.removeTouchExplorationStateChangeListener(accessibilityManager, touchExplorationStateChangeListener);
}
}
accessibilityStateChangeListener = null;
touchExplorationStateChangeListener = null;
Application.off(Application.resumeEvent, updateAccessibilityServiceState);
});
Application.on(Application.resumeEvent, updateAccessibilityServiceState);
return accessibilityServiceEnabled;
}
export function setupAccessibleView(view: View): void {
updateAccessibilityProperties(view);
}
export function updateAccessibilityProperties(view: View): void {
if (!view.nativeViewProtected) {
return;
}
setAccessibilityDelegate(view);
applyContentDescription(view);
}
export function sendAccessibilityEvent(view: View, eventType: AndroidAccessibilityEvent, text?: string): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
const cls = `sendAccessibilityEvent(${view}, ${eventType}, ${text})`;
const androidView = view.nativeViewProtected as android.view.View;
if (!androidView) {
if (Trace.isEnabled()) {
Trace.write(`${cls}: no nativeView`, Trace.categories.Accessibility);
}
return;
}
if (!eventType) {
if (Trace.isEnabled()) {
Trace.write(`${cls}: no eventName provided`, Trace.categories.Accessibility);
}
return;
}
if (!isAccessibilityServiceEnabled()) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - TalkBack not enabled`, Trace.categories.Accessibility);
}
return;
}
const accessibilityManager = getAndroidAccessibilityManager();
if (!accessibilityManager?.isEnabled()) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - accessibility service not enabled`, Trace.categories.Accessibility);
}
return;
}
if (!accessibilityEventMap.has(eventType)) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - unknown event`, Trace.categories.Accessibility);
}
return;
}
const eventInt = accessibilityEventMap.get(eventType);
if (!text) {
return androidView.sendAccessibilityEvent(eventInt);
}
const accessibilityEvent = android.view.accessibility.AccessibilityEvent.obtain(eventInt);
accessibilityEvent.setSource(androidView);
accessibilityEvent.getText().clear();
if (!text) {
applyContentDescription(view);
text = androidView.getContentDescription() || view['title'];
if (Trace.isEnabled()) {
Trace.write(`${cls} - text not provided use androidView.getContentDescription() - ${text}`, Trace.categories.Accessibility);
}
}
if (Trace.isEnabled()) {
Trace.write(`${cls}: send event with text: '${JSON.stringify(text)}'`, Trace.categories.Accessibility);
}
if (text) {
accessibilityEvent.getText().add(text);
}
accessibilityManager.sendAccessibilityEvent(accessibilityEvent);
}
export function updateContentDescription(view: View, forceUpdate?: boolean): string | null {
if (!view.nativeViewProtected) {
return;
}
return applyContentDescription(view, forceUpdate);
}
function setAccessibilityDelegate(view: View): void {
if (!view.nativeViewProtected) {
return;
}
ensureNativeClasses();
const androidView = view.nativeViewProtected as android.view.View;
if (!androidView) {
return;
}
androidViewToTNSView.set(androidView, new WeakRef(view));
const hasOldDelegate = androidView.getAccessibilityDelegate() === TNSAccessibilityDelegate;
if (hasOldDelegate) {
return;
}
androidView.setAccessibilityDelegate(TNSAccessibilityDelegate);
}
function applyContentDescription(view: View, forceUpdate?: boolean) {
let androidView = view.nativeViewProtected as android.view.View;
if (!androidView) {
return;
}
if (androidView instanceof androidx.appcompat.widget.Toolbar) {
const numChildren = androidView.getChildCount();
for (let i = 0; i < numChildren; i += 1) {
const childAndroidView = androidView.getChildAt(i);
if (childAndroidView instanceof androidx.appcompat.widget.AppCompatTextView) {
androidView = childAndroidView;
break;
}
}
}
const cls = `applyContentDescription(${view})`;
if (!androidView) {
return null;
}
const titleValue = view['title'] as string;
const textValue = view['text'] as string;
if (!forceUpdate && view._androidContentDescriptionUpdated === false && textValue === view['_lastText'] && titleValue === view['_lastTitle']) {
// prevent updating this too much
return androidView.getContentDescription();
}
const contentDescriptionBuilder = new Array<string>();
// Workaround: TalkBack won't read the checked state for fake Switch.
if (view.accessibilityRole === AccessibilityRole.Switch) {
const androidSwitch = new android.widget.Switch(Application.android.context);
if (view.accessibilityState === AccessibilityState.Checked) {
contentDescriptionBuilder.push(androidSwitch.getTextOn());
} else {
contentDescriptionBuilder.push(androidSwitch.getTextOff());
}
}
if (view.accessibilityLabel) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - have accessibilityLabel`, Trace.categories.Accessibility);
}
contentDescriptionBuilder.push(`${view.accessibilityLabel}`);
}
if (view.accessibilityValue) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - have accessibilityValue`, Trace.categories.Accessibility);
}
contentDescriptionBuilder.push(`${view.accessibilityValue}`);
} else if (textValue) {
if (textValue !== view.accessibilityLabel) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - don't have accessibilityValue - use 'text' value`, Trace.categories.Accessibility);
}
contentDescriptionBuilder.push(`${textValue}`);
}
} else if (titleValue) {
if (titleValue !== view.accessibilityLabel) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - don't have accessibilityValue - use 'title' value`, Trace.categories.Accessibility);
}
contentDescriptionBuilder.push(`${titleValue}`);
}
}
if (view.accessibilityHint) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - have accessibilityHint`, Trace.categories.Accessibility);
}
contentDescriptionBuilder.push(`${view.accessibilityHint}`);
}
const contentDescription = contentDescriptionBuilder.join('. ').trim().replace(/^\.$/, '');
if (contentDescription) {
if (Trace.isEnabled()) {
Trace.write(`${cls} - set to "${contentDescription}"`, Trace.categories.Accessibility);
}
androidView.setContentDescription(contentDescription);
} else {
if (Trace.isEnabled()) {
Trace.write(`${cls} - remove value`, Trace.categories.Accessibility);
}
androidView.setContentDescription(null);
}
view['_lastTitle'] = titleValue;
view['_lastText'] = textValue;
view._androidContentDescriptionUpdated = false;
return contentDescription;
}

36
packages/core/accessibility/index.d.ts vendored Normal file
View File

@ -0,0 +1,36 @@
import { View } from '../ui/core/view';
import { AndroidAccessibilityEvent } from './accessibility-types';
export * from './accessibility-common';
export * from './accessibility-types';
export * from './font-scale';
/**
* Initialize accessibility for View. This should be called on loaded-event.
*/
export function setupAccessibleView(view: View): void;
/**
* Update accessibility properties on nativeView
*/
export function updateAccessibilityProperties(view: View): void;
/**
* Android helper function for triggering accessibility events
*/
export function sendAccessibilityEvent(View: View, eventName: AndroidAccessibilityEvent, text?: string): void;
/**
* Update the content description for android views
*/
export function updateContentDescription(View: View, forceUpdate?: boolean): string | null;
/**
* Is Android TalkBack or iOS VoiceOver enabled?
*/
export function isAccessibilityServiceEnabled(): boolean;
/**
* Find the last view focused on a page.
*/
export function getLastFocusedViewOnPage(page: Page): View | null;

View File

@ -0,0 +1,264 @@
import * as Application from '../application';
import type { View } from '../ui/core/view';
import { notifyAccessibilityFocusState } from './accessibility-common';
import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait } from './accessibility-types';
export * from './accessibility-common';
export * from './accessibility-types';
export * from './font-scale';
function enforceArray(val: string | string[]): string[] {
if (Array.isArray(val)) {
return val;
}
if (typeof val === 'string') {
return val.split(/[, ]/g).filter((v: string) => !!v);
}
return [];
}
/**
* Convert array of values into a bitmask.
*
* @param values string values
* @param map map lower-case name to integer value.
*/
function inputArrayToBitMask(values: string | string[], map: Map<string, number>): number {
return (
enforceArray(values)
.filter((value) => !!value)
.map((value) => `${value}`.toLocaleLowerCase())
.filter((value) => map.has(value))
.reduce((res, value) => res | map.get(value), 0) || 0
);
}
let AccessibilityTraitsMap: Map<string, number>;
let RoleTypeMap: Map<AccessibilityRole, number>;
let nativeFocusedNotificationObserver;
let lastFocusedView: WeakRef<View>;
function ensureNativeClasses() {
if (AccessibilityTraitsMap && nativeFocusedNotificationObserver) {
return;
}
AccessibilityTraitsMap = new Map<AccessibilityTrait, number>([
[AccessibilityTrait.None, UIAccessibilityTraitNone],
[AccessibilityTrait.Button, UIAccessibilityTraitButton],
[AccessibilityTrait.Link, UIAccessibilityTraitLink],
[AccessibilityTrait.SearchField, UIAccessibilityTraitSearchField],
[AccessibilityTrait.Image, UIAccessibilityTraitImage],
[AccessibilityTrait.Selected, UIAccessibilityTraitSelected],
[AccessibilityTrait.PlaysSound, UIAccessibilityTraitPlaysSound],
[AccessibilityTrait.StaticText, UIAccessibilityTraitStaticText],
[AccessibilityTrait.SummaryElement, UIAccessibilityTraitSummaryElement],
[AccessibilityTrait.NotEnabled, UIAccessibilityTraitNotEnabled],
[AccessibilityTrait.UpdatesFrequently, UIAccessibilityTraitUpdatesFrequently],
[AccessibilityTrait.StartsMediaSession, UIAccessibilityTraitStartsMediaSession],
[AccessibilityTrait.Adjustable, UIAccessibilityTraitAdjustable],
[AccessibilityTrait.AllowsDirectInteraction, UIAccessibilityTraitAllowsDirectInteraction],
[AccessibilityTrait.CausesPageTurn, UIAccessibilityTraitCausesPageTurn],
[AccessibilityTrait.Header, UIAccessibilityTraitHeader],
]);
RoleTypeMap = new Map<AccessibilityRole, number>([
[AccessibilityRole.Button, UIAccessibilityTraitButton],
[AccessibilityRole.Header, UIAccessibilityTraitHeader],
[AccessibilityRole.Link, UIAccessibilityTraitLink],
[AccessibilityRole.Search, UIAccessibilityTraitSearchField],
[AccessibilityRole.Image, UIAccessibilityTraitImage],
[AccessibilityRole.ImageButton, UIAccessibilityTraitImage | UIAccessibilityTraitButton],
[AccessibilityRole.KeyboardKey, UIAccessibilityTraitKeyboardKey],
[AccessibilityRole.StaticText, UIAccessibilityTraitStaticText],
[AccessibilityRole.Summary, UIAccessibilityTraitSummaryElement],
[AccessibilityRole.Adjustable, UIAccessibilityTraitAdjustable],
[AccessibilityRole.Checkbox, UIAccessibilityTraitButton],
[AccessibilityRole.Switch, UIAccessibilityTraitButton],
[AccessibilityRole.RadioButton, UIAccessibilityTraitButton],
]);
nativeFocusedNotificationObserver = Application.ios.addNotificationObserver(UIAccessibilityElementFocusedNotification, (args: NSNotification) => {
const uiView = args.userInfo.objectForKey(UIAccessibilityFocusedElementKey) as UIView;
if (!uiView.tag) {
return;
}
const rootView = Application.getRootView();
// We use the UIView's tag to find the NativeScript View by its domId.
let view = rootView.getViewByDomId<View>(uiView.tag);
if (!view) {
for (const modalView of <Array<View>>rootView._getRootModalViews()) {
view = modalView.getViewByDomId(uiView.tag);
if (view) {
break;
}
}
}
if (!view) {
return;
}
const lastView = lastFocusedView?.get();
if (lastView && view !== lastView) {
const lastFocusedUIView = lastView.nativeViewProtected as UIView;
if (lastFocusedUIView) {
lastFocusedView = null;
notifyAccessibilityFocusState(lastView, false, true);
}
}
lastFocusedView = new WeakRef(view);
notifyAccessibilityFocusState(view, true, false);
});
Application.on(Application.exitEvent, () => {
if (nativeFocusedNotificationObserver) {
Application.ios.removeNotificationObserver(nativeFocusedNotificationObserver, UIAccessibilityElementFocusedNotification);
}
nativeFocusedNotificationObserver = null;
lastFocusedView = null;
});
}
export function setupAccessibleView(view: View): void {
const uiView = view.nativeViewProtected as UIView;
if (!uiView) {
return;
}
/**
* We need to map back from the UIView to the NativeScript View.
*
* We do that by setting the uiView's tag to the View's domId.
* This way we can do reverse lookup.
*/
uiView.tag = view._domId;
}
export function updateAccessibilityProperties(view: View): void {
const uiView = view.nativeViewProtected as UIView;
if (!uiView) {
return;
}
ensureNativeClasses();
const accessibilityRole = view.accessibilityRole;
const accessibilityState = view.accessibilityState;
if (!view.accessible || view.accessibilityHidden) {
uiView.accessibilityTraits = UIAccessibilityTraitNone;
return;
}
let a11yTraits = UIAccessibilityTraitNone;
if (RoleTypeMap.has(accessibilityRole)) {
a11yTraits |= RoleTypeMap.get(accessibilityRole);
}
switch (accessibilityRole) {
case AccessibilityRole.Checkbox:
case AccessibilityRole.RadioButton:
case AccessibilityRole.Switch: {
if (accessibilityState === AccessibilityState.Checked) {
a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.Selected);
}
break;
}
default: {
if (accessibilityState === AccessibilityState.Selected) {
a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.Selected);
}
if (accessibilityState === AccessibilityState.Disabled) {
a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.NotEnabled);
}
break;
}
}
const UpdatesFrequentlyTrait = AccessibilityTraitsMap.get(AccessibilityTrait.UpdatesFrequently);
switch (view.accessibilityLiveRegion) {
case AccessibilityLiveRegion.Polite:
case AccessibilityLiveRegion.Assertive: {
a11yTraits |= UpdatesFrequentlyTrait;
break;
}
default: {
a11yTraits &= ~UpdatesFrequentlyTrait;
break;
}
}
if (view.accessibilityMediaSession) {
a11yTraits |= AccessibilityTraitsMap.get(AccessibilityTrait.StartsMediaSession);
}
if (view.accessibilityTraits) {
a11yTraits |= inputArrayToBitMask(view.accessibilityTraits, AccessibilityTraitsMap);
}
uiView.accessibilityTraits = a11yTraits;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
export const sendAccessibilityEvent = (): void => {};
export const updateContentDescription = (): string | null => null;
let accessibilityServiceEnabled: boolean;
let nativeObserver;
export function isAccessibilityServiceEnabled(): boolean {
if (typeof accessibilityServiceEnabled === 'boolean') {
return accessibilityServiceEnabled;
}
let isVoiceOverRunning: () => boolean;
if (typeof UIAccessibilityIsVoiceOverRunning === 'function') {
isVoiceOverRunning = UIAccessibilityIsVoiceOverRunning;
} else {
// iOS is too old to tell us if voice over is enabled
if (typeof UIAccessibilityIsVoiceOverRunning !== 'function') {
accessibilityServiceEnabled = false;
return accessibilityServiceEnabled;
}
}
accessibilityServiceEnabled = isVoiceOverRunning();
let voiceOverStatusChangedNotificationName: string | null = null;
if (typeof UIAccessibilityVoiceOverStatusDidChangeNotification !== 'undefined') {
voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusDidChangeNotification;
} else if (typeof UIAccessibilityVoiceOverStatusChanged !== 'undefined') {
voiceOverStatusChangedNotificationName = UIAccessibilityVoiceOverStatusChanged;
}
if (voiceOverStatusChangedNotificationName) {
nativeObserver = Application.ios.addNotificationObserver(voiceOverStatusChangedNotificationName, () => {
accessibilityServiceEnabled = isVoiceOverRunning();
});
Application.on(Application.exitEvent, () => {
if (nativeObserver) {
Application.ios.removeNotificationObserver(nativeObserver, voiceOverStatusChangedNotificationName);
}
accessibilityServiceEnabled = undefined;
nativeObserver = null;
});
}
Application.on(Application.resumeEvent, () => {
accessibilityServiceEnabled = isVoiceOverRunning();
});
return accessibilityServiceEnabled;
}

View File

@ -28,6 +28,7 @@ export const uncaughtErrorEvent = 'uncaughtError';
export const discardedErrorEvent = 'discardedError';
export const orientationChangedEvent = 'orientationChanged';
export const systemAppearanceChangedEvent = 'systemAppearanceChanged';
export const fontScaleChangedEvent = 'fontScaleChanged';
const ORIENTATION_CSS_CLASSES = [`${CSSUtils.CLASS_PREFIX}${Enums.DeviceOrientation.portrait}`, `${CSSUtils.CLASS_PREFIX}${Enums.DeviceOrientation.landscape}`, `${CSSUtils.CLASS_PREFIX}${Enums.DeviceOrientation.unknown}`];
@ -122,7 +123,7 @@ function increaseStyleScopeApplicationCssSelectorVersion(rootView: View) {
}
}
function applyCssClass(rootView: View, cssClasses: string[], newCssClass: string) {
export function applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void {
if (!rootView.cssClasses.has(newCssClass)) {
cssClasses.forEach((cssClass) => removeCssClass(rootView, cssClass));
addCssClass(rootView, newCssClass);
@ -146,7 +147,7 @@ export function orientationChanged(rootView: View, newOrientation: 'portrait' |
}
export let autoSystemAppearanceChanged = true;
export function setAutoSystemAppearanceChanged(value: boolean) {
export function setAutoSystemAppearanceChanged(value: boolean): void {
autoSystemAppearanceChanged = value;
}

View File

@ -15,6 +15,8 @@ import { NavigationEntry, AndroidActivityCallbacks } from '../ui/frame/frame-int
import { Observable } from '../data/observable';
import { profile } from '../profiling';
import { initAccessibilityCssHelper } from '../accessibility/accessibility-css-helper';
import { initAccessibilityFontScale } from '../accessibility/font-scale';
const ActivityCreated = 'activityCreated';
const ActivityDestroyed = 'activityDestroyed';
@ -172,6 +174,9 @@ export function run(entry?: NavigationEntry | string) {
const nativeApp = getNativeApplication();
androidApp.init(nativeApp);
}
initAccessibilityCssHelper();
initAccessibilityFontScale();
}
export function addCss(cssText: string, attributeScoped?: boolean): void {

View File

@ -54,6 +54,11 @@ export const orientationChangedEvent: string;
*/
export const systemAppearanceChangedEvent: string;
/**
* String value used when hooking to fontScaleChanged event.
*/
export const fontScaleChangedEvent: string;
/**
* Boolean to enable/disable systemAppearanceChanged
*/
@ -62,7 +67,7 @@ export let autoSystemAppearanceChanged: boolean;
/**
* enable/disable systemAppearanceChanged
*/
export function setAutoSystemAppearanceChanged(value: boolean);
export function setAutoSystemAppearanceChanged(value: boolean): void;
/**
* Updates root view classes including those of modals
@ -184,6 +189,11 @@ export function setCssFileName(cssFile: string): void;
*/
export function getCssFileName(): string;
/**
* Ensure css-class is set on rootView
*/
export function applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void;
/**
* Loads immediately the app.css.
* By default the app.css file is loaded shortly after "loaded".

View File

@ -18,6 +18,8 @@ import { IOSHelper } from '../ui/core/view/view-helper';
import { Device } from '../platform';
import { profile } from '../profiling';
import { iOSNativeHelper } from '../utils';
import { initAccessibilityCssHelper } from '../accessibility/accessibility-css-helper';
import { initAccessibilityFontScale } from '../accessibility/font-scale';
const IOS_PLATFORM = 'ios';
@ -435,6 +437,9 @@ export function run(entry?: string | NavigationEntry) {
}
}
}
initAccessibilityCssHelper();
initAccessibilityFontScale();
}
export function addCss(cssText: string, attributeScoped?: boolean): void {

View File

@ -185,6 +185,7 @@ export namespace Trace {
* all predefined categories.
*/
export namespace categories {
export const Accessibility = 'Accessibility';
export const VisualTreeEvents = 'VisualTreeEvents';
export const Layout = 'Layout';
export const Style = 'Style';

View File

@ -6,6 +6,7 @@ import { layout, RESOURCE_PREFIX, isFontIconURI } from '../../utils';
import { colorProperty } from '../styling/style-properties';
import { ImageSource } from '../../image-source';
import * as application from '../../application';
import { isAccessibilityServiceEnabled, updateContentDescription } from '../../accessibility';
export * from './action-bar-common';
@ -298,7 +299,7 @@ export class ActionBar extends ActionBarBase {
}
}
public _updateTitleAndTitleView() {
public _updateTitleAndTitleView(): void {
if (!this.titleView) {
// No title view - show the title
const title = this.title;
@ -313,6 +314,9 @@ export class ActionBar extends ActionBarBase {
}
}
}
// Update content description for the screen reader.
updateContentDescription(this, true);
}
public _addActionItems() {
@ -447,6 +451,74 @@ export class ActionBar extends ActionBarBase {
this.nativeViewProtected.setContentInsetsAbsolute(this.effectiveContentInsetLeft, this.effectiveContentInsetRight);
}
}
public accessibilityScreenChanged(): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
const nativeView = this.nativeViewProtected;
if (!nativeView) {
return;
}
const originalFocusableState = android.os.Build.VERSION.SDK_INT >= 26 && nativeView.getFocusable();
const originalImportantForAccessibility = nativeView.getImportantForAccessibility();
const originalIsAccessibilityHeading = android.os.Build.VERSION.SDK_INT >= 28 && nativeView.isAccessibilityHeading();
try {
nativeView.setFocusable(false);
nativeView.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO);
let announceView: android.view.View | null = null;
const numChildren = nativeView.getChildCount();
for (let i = 0; i < numChildren; i += 1) {
const childView = nativeView.getChildAt(i);
if (!childView) {
continue;
}
childView.setFocusable(true);
if (childView instanceof androidx.appcompat.widget.AppCompatTextView) {
announceView = childView;
if (android.os.Build.VERSION.SDK_INT >= 28) {
announceView.setAccessibilityHeading(true);
}
}
}
if (!announceView) {
announceView = nativeView;
}
announceView.setFocusable(true);
announceView.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES);
announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED);
announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
} catch {
// ignore
} finally {
setTimeout(() => {
// Reset status after the focus have been reset.
const localNativeView = this.nativeViewProtected;
if (!localNativeView) {
return;
}
if (android.os.Build.VERSION.SDK_INT >= 28) {
nativeView.setAccessibilityHeading(originalIsAccessibilityHeading);
}
if (android.os.Build.VERSION.SDK_INT >= 26) {
localNativeView.setFocusable(originalFocusableState);
}
localNativeView.setImportantForAccessibility(originalImportantForAccessibility);
});
}
}
}
function getAppCompatTextView(toolbar: androidx.appcompat.widget.Toolbar): typeof AppCompatTextView {

View File

@ -5,6 +5,7 @@ import { Color } from '../../color';
import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties';
import { ImageSource } from '../../image-source';
import { layout, iOSNativeHelper, isFontIconURI } from '../../utils';
import { accessibilityHintProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityValueProperty } from '../../accessibility/accessibility-properties';
export * from './action-bar-common';
@ -99,6 +100,46 @@ export class ActionBar extends ActionBarBase {
return null;
}
[accessibilityValueProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityValue = value;
const navigationItem = this._getNavigationItem();
if (navigationItem) {
navigationItem.accessibilityValue = value;
}
}
[accessibilityLabelProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityLabel = value;
const navigationItem = this._getNavigationItem();
if (navigationItem) {
navigationItem.accessibilityLabel = value;
}
}
[accessibilityHintProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityHint = value;
const navigationItem = this._getNavigationItem();
if (navigationItem) {
navigationItem.accessibilityHint = value;
}
}
[accessibilityLanguageProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityLanguage = value;
const navigationItem = this._getNavigationItem();
if (navigationItem) {
navigationItem.accessibilityLanguage = value;
}
}
public createNativeView(): UIView {
return this.ios;
}
@ -148,7 +189,18 @@ export class ActionBar extends ActionBarBase {
}
}
public update() {
private _getNavigationItem(): UINavigationItem | null {
const page = this.page;
// Page should be attached to frame to update the action bar.
if (!page || !page.frame) {
return null;
}
const viewController = <UIViewController>page.ios;
return viewController.navigationItem;
}
public update(): void {
const page = this.page;
// Page should be attached to frame to update the action bar.
if (!page || !page.frame) {
@ -228,6 +280,12 @@ export class ActionBar extends ActionBarBase {
if (!this.isLayoutValid) {
this.layoutInternal();
}
// Make sure accessibility values are up-to-date on the navigationItem
navigationItem.accessibilityValue = this.accessibilityValue;
navigationItem.accessibilityLabel = this.accessibilityLabel;
navigationItem.accessibilityLanguage = this.accessibilityLanguage;
navigationItem.accessibilityHint = this.accessibilityHint;
}
private populateMenuItems(navigationItem: UINavigationItem) {

View File

@ -2,11 +2,15 @@ import { Button as ButtonDefinition } from '.';
import { TextBase } from '../text-base';
import { CSSType } from '../core/view';
import { booleanConverter } from '../core/view-base';
import { AccessibilityRole } from '../../accessibility';
@CSSType('Button')
export abstract class ButtonBase extends TextBase implements ButtonDefinition {
public static tapEvent = 'tap';
accessible = true;
accessibilityRole = AccessibilityRole.Button;
get textWrap(): boolean {
return this.style.whiteSpace === 'normal';
}

View File

@ -34,6 +34,14 @@ export function isEventOrGesture(name: string, view: ViewBase): boolean;
*/
export function getViewById(view: ViewBase, id: string): ViewBase;
/**
* Gets a child view by domId.
* @param view - The parent (container) view of the view to look for.
* @param domId - The id of the view to look for.
* Returns an instance of a view (if found), otherwise undefined.
*/
export function getViewByDomId(view: ViewBase, domId: number): ViewBase;
export interface ShowModalOptions {
/**
* Any context you want to pass to the modally shown view. This same context will be available in the arguments of the shownModally event handler.
@ -289,6 +297,11 @@ export abstract class ViewBase extends Observable {
*/
public getViewById<T extends ViewBase>(id: string): T;
/**
* Returns the child view with the specified domId.
*/
public getViewByDomId<T extends ViewBase>(id: number): T;
/**
* Load view.
* @param view to load.

View File

@ -143,6 +143,32 @@ export function getViewById(view: ViewBaseDefinition, id: string): ViewBaseDefin
return retVal;
}
export function getViewByDomId(view: ViewBaseDefinition, domId: number): ViewBaseDefinition {
if (!view) {
return undefined;
}
if (view._domId === domId) {
return view;
}
let retVal: ViewBaseDefinition;
const descendantsCallback = function (child: ViewBaseDefinition): boolean {
if (view._domId === domId) {
retVal = child;
// break the iteration by returning false
return false;
}
return true;
};
eachDescendant(view, descendantsCallback);
return retVal;
}
export function eachDescendant(view: ViewBaseDefinition, callback: (child: ViewBaseDefinition) => boolean) {
if (!callback || !view) {
return;
@ -373,6 +399,10 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition
return <T>getViewById(this, id);
}
getViewByDomId<T extends ViewBaseDefinition>(domId: number): T {
return <T>getViewByDomId(this, domId);
}
get page(): Page {
if (this.parent) {
return this.parent.page;

View File

@ -1,8 +1,9 @@
// Definitions.
import { Point, CustomLayoutView as CustomLayoutViewDefinition, dip } from '.';
import { GestureTypes, GestureEventData } from '../../gestures';
import type { Point, CustomLayoutView as CustomLayoutViewDefinition, dip } from '.';
import type { GestureTypes, GestureEventData } from '../../gestures';
// Types.
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, automationTextProperty, isUserInteractionEnabledProperty } from './view-common';
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty } from './view-common';
import { paddingLeftProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty } from '../../styling/style-properties';
import { layout } from '../../../utils';
import { Trace } from '../../../trace';
@ -48,6 +49,9 @@ import { Screen } from '../../../platform';
import { AndroidActivityBackPressedEventData, android as androidApp } from '../../../application';
import { Device } from '../../../platform';
import lazy from '../../../utils/lazy';
import { accessibilityEnabledProperty, accessibilityHiddenProperty, accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityLiveRegionProperty, accessibilityMediaSessionProperty, accessibilityRoleProperty, accessibilityStateProperty, accessibilityValueProperty } from '../../../accessibility/accessibility-properties';
import { AccessibilityLiveRegion, AccessibilityRole, AndroidAccessibilityEvent, setupAccessibleView, isAccessibilityServiceEnabled, sendAccessibilityEvent, updateAccessibilityProperties, updateContentDescription } from '../../../accessibility';
import * as Utils from '../../../utils';
export * from './view-common';
// helpers (these are okay re-exported here)
@ -323,6 +327,12 @@ export class View extends ViewCommon {
nativeViewProtected: android.view.View;
constructor() {
super();
this.on(View.loadedEvent, () => setupAccessibleView(this));
}
// TODO: Implement unobserve that detach the touchListener.
_observe(type: GestureTypes, callback: (args: GestureEventData) => void, thisArg?: any): void {
super._observe(type, callback, thisArg);
@ -744,13 +754,6 @@ export class View extends ViewCommon {
org.nativescript.widgets.OriginPoint.setY(this.nativeViewProtected, value);
}
[automationTextProperty.getDefault](): string {
return this.nativeViewProtected.getContentDescription();
}
[automationTextProperty.setNative](value: string) {
this.nativeViewProtected.setContentDescription(value);
}
[isUserInteractionEnabledProperty.setNative](value: boolean) {
this.nativeViewProtected.setClickable(value);
this.nativeViewProtected.setFocusable(value);
@ -792,6 +795,77 @@ export class View extends ViewCommon {
this.nativeViewProtected.setAlpha(float(value));
}
[accessibilityEnabledProperty.setNative](value: boolean): void {
this.nativeViewProtected.setFocusable(!!value);
updateAccessibilityProperties(this);
}
[accessibilityIdentifierProperty.setNative](value: string): void {
const id = Utils.ad.resources.getId(':id/nativescript_accessibility_id');
if (id) {
this.nativeViewProtected.setTag(id, value);
this.nativeViewProtected.setTag(value);
}
}
[accessibilityRoleProperty.setNative](value: AccessibilityRole): void {
updateAccessibilityProperties(this);
if (android.os.Build.VERSION.SDK_INT >= 28) {
this.nativeViewProtected?.setAccessibilityHeading(value === AccessibilityRole.Header);
}
}
[accessibilityValueProperty.setNative](): void {
this._androidContentDescriptionUpdated = true;
updateContentDescription(this);
}
[accessibilityLabelProperty.setNative](): void {
this._androidContentDescriptionUpdated = true;
updateContentDescription(this);
}
[accessibilityHintProperty.setNative](): void {
this._androidContentDescriptionUpdated = true;
updateContentDescription(this);
}
[accessibilityHiddenProperty.setNative](value: boolean): void {
if (value) {
this.nativeViewProtected.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
} else {
this.nativeViewProtected.setImportantForAccessibility(android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES);
}
}
[accessibilityLiveRegionProperty.setNative](value: AccessibilityLiveRegion): void {
switch (value) {
case AccessibilityLiveRegion.Assertive: {
this.nativeViewProtected.setAccessibilityLiveRegion(android.view.View.ACCESSIBILITY_LIVE_REGION_ASSERTIVE);
break;
}
case AccessibilityLiveRegion.Polite: {
this.nativeViewProtected.setAccessibilityLiveRegion(android.view.View.ACCESSIBILITY_LIVE_REGION_POLITE);
break;
}
default: {
this.nativeViewProtected.setAccessibilityLiveRegion(android.view.View.ACCESSIBILITY_LIVE_REGION_NONE);
break;
}
}
}
[accessibilityStateProperty.setNative](): void {
updateAccessibilityProperties(this);
}
[accessibilityMediaSessionProperty.setNative](): void {
updateAccessibilityProperties(this);
}
[androidElevationProperty.getDefault](): number {
return this.getDefaultElevation();
}
@ -1047,6 +1121,30 @@ export class View extends ViewCommon {
(<any>nativeView).background = undefined;
}
}
public androidSendAccessibilityEvent(eventName: AndroidAccessibilityEvent, msg?: string): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
sendAccessibilityEvent(this, eventName, msg);
}
public accessibilityAnnouncement(msg = this.accessibilityLabel): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
this.androidSendAccessibilityEvent(AndroidAccessibilityEvent.ANNOUNCEMENT, msg);
}
public accessibilityScreenChanged(): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
this.androidSendAccessibilityEvent(AndroidAccessibilityEvent.WINDOW_STATE_CHANGED);
}
}
export class ContainerView extends View {

View File

@ -4,8 +4,9 @@ import { EventData } from '../../../data/observable';
import { Color } from '../../../color';
import { Animation, AnimationDefinition, AnimationPromise } from '../../animation';
import { HorizontalAlignment, VerticalAlignment, Visibility, Length, PercentLength } from '../../styling/style-properties';
import { GestureTypes, GestureEventData, GesturesObserver } from '../../gestures';
import { GestureTypes, GesturesObserver } from '../../gestures';
import { LinearGradient } from '../../styling/gradient';
import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait, AndroidAccessibilityEvent, IOSPostAccessibilityNotificationType } from '../../../accessibility/accessibility-types';
import { BoxShadow } from '../../styling/box-shadow';
// helpers (these are okay re-exported here)
@ -131,6 +132,21 @@ export abstract class View extends ViewBase {
*/
public static shownModallyEvent: string;
/**
* String value used when hooking to accessibilityBlur event.
*/
public static accessibilityBlurEvent: string;
/**
* String value used when hooking to accessibilityFocus event.
*/
public static accessibilityFocusEvent: string;
/**
* String value used when hooking to accessibilityFocusChanged event.
*/
public static accessibilityFocusChangedEvent: string;
/**
* Gets the android-specific native instance that lies behind this proxy. Will be available if running on an Android platform.
*/
@ -226,6 +242,68 @@ export abstract class View extends ViewBase {
*/
color: Color;
/**
* If `true` the element is an accessibility element and all the children will be treated as a single selectable component.
*/
accessible: boolean;
/**
* Hide the view and its children from the a11y service
*/
accessibilityHidden: boolean;
/**
* The view's unique accessibilityIdentifier.
*
* This is used for automated testing.
*/
accessibilityIdentifier: string;
/**
* Which role should this view be treated by the a11y service?
*/
accessibilityRole: AccessibilityRole;
/**
* Which state should this view be treated as by the a11y service?
*/
accessibilityState: AccessibilityState;
/**
* Short description of the element, ideally one word.
*/
accessibilityLabel: string;
/**
* Current value of the element in a localized string.
*/
accessibilityValue: string;
/**
* A hint describes the elements behavior. Example: 'Tap change playback speed'
*/
accessibilityHint: string;
accessibilityTraits?: AccessibilityTrait[];
accessibilityLiveRegion: AccessibilityLiveRegion;
/**
* Sets the language in which to speak the element's label and value.
* Accepts language ID tags that follows the "BCP 47" specification.
*/
accessibilityLanguage: string;
/**
* This view starts a media session. Equivalent to trait = startsMedia
*/
accessibilityMediaSession: boolean;
/**
* Internal use only. This is used to limit the number of updates to android.view.View.setContentDescription()
*/
_androidContentDescriptionUpdated?: boolean;
automationText: string;
/**
* Gets or sets the elevation of the android view.
*/
@ -364,11 +442,6 @@ export abstract class View extends ViewBase {
//END Style property shortcuts
/**
* Gets or sets the automation text of the view.
*/
automationText: string;
/**
* Gets or sets the X component of the origin point around which the view will be transformed. The default value is 0.5 representing the center of the view.
*/
@ -673,6 +746,29 @@ export abstract class View extends ViewBase {
*/
public eachChildView(callback: (view: View) => boolean): void;
/**
* Android: Send accessibility event
*/
public androidSendAccessibilityEvent(eventName: AndroidAccessibilityEvent, msg?: string): void;
/**
* iOS: post accessibility notification.
* type = 'announcement' will announce `args` via VoiceOver. If no args element will be announced instead.
* type = 'layout' used when the layout of a screen changes.
* type = 'screen' large change made to the screen.
*/
public iosPostAccessibilityNotification(notificationType: IOSPostAccessibilityNotificationType, msg?: string): void;
/**
* Make an announcement to the screen reader.
*/
public accessibilityAnnouncement(msg?: string): void;
/**
* Announce screen changed
*/
public accessibilityScreenChanged(): void;
//@private
/**
* @private
@ -879,7 +975,6 @@ export interface AddChildFromBuilder {
_addChildFromBuilder(name: string, value: any): void;
}
export const automationTextProperty: Property<View, string>;
export const originXProperty: Property<View, number>;
export const originYProperty: Property<View, number>;
export const isEnabledProperty: Property<View, boolean>;

View File

@ -2,7 +2,7 @@
import { Point, View as ViewDefinition, dip } from '.';
// Requires
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, automationTextProperty, isUserInteractionEnabledProperty } from './view-common';
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty } from './view-common';
import { ShowModalOptions } from '../view-base';
import { Trace } from '../../../trace';
import { layout, iOSNativeHelper } from '../../../utils';
@ -10,6 +10,8 @@ import { IOSHelper } from './view-helper';
import { ios as iosBackground, Background } from '../../styling/background';
import { perspectiveProperty, Visibility, visibilityProperty, opacityProperty, rotateProperty, rotateXProperty, rotateYProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, zIndexProperty, backgroundInternalProperty, clipPathProperty } from '../../styling/style-properties';
import { profile } from '../../../profiling';
import { accessibilityEnabledProperty, accessibilityHiddenProperty, accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityLanguageProperty, accessibilityLiveRegionProperty, accessibilityMediaSessionProperty, accessibilityRoleProperty, accessibilityStateProperty, accessibilityTraitsProperty, accessibilityValueProperty } from '../../../accessibility/accessibility-properties';
import { setupAccessibleView, IOSPostAccessibilityNotificationType, isAccessibilityServiceEnabled, updateAccessibilityProperties } from '../../../accessibility';
export * from './view-common';
// helpers (these are okay re-exported here)
@ -54,6 +56,12 @@ export class View extends ViewCommon implements ViewDefinition {
return (this._privateFlags & PFLAG_FORCE_LAYOUT) === PFLAG_FORCE_LAYOUT;
}
constructor() {
super();
this.once(View.loadedEvent, () => setupAccessibleView(this));
}
public requestLayout(): void {
super.requestLayout();
this._privateFlags |= PFLAG_FORCE_LAYOUT;
@ -553,14 +561,65 @@ export class View extends ViewCommon implements ViewDefinition {
this.updateOriginPoint(this.originX, value);
}
[automationTextProperty.getDefault](): string {
[accessibilityEnabledProperty.setNative](value: boolean): void {
this.nativeViewProtected.isAccessibilityElement = !!value;
updateAccessibilityProperties(this);
}
[accessibilityIdentifierProperty.getDefault](): string {
return this.nativeViewProtected.accessibilityLabel;
}
[automationTextProperty.setNative](value: string) {
[accessibilityIdentifierProperty.setNative](value: string): void {
this.nativeViewProtected.accessibilityIdentifier = value;
}
[accessibilityRoleProperty.setNative](): void {
updateAccessibilityProperties(this);
}
[accessibilityTraitsProperty.setNative](): void {
updateAccessibilityProperties(this);
}
[accessibilityValueProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityValue = value;
}
[accessibilityLabelProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityLabel = value;
}
[accessibilityHintProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityHint = value;
}
[accessibilityLanguageProperty.setNative](value: string): void {
value = value == null ? null : `${value}`;
this.nativeViewProtected.accessibilityLanguage = value;
}
[accessibilityHiddenProperty.setNative](value: boolean): void {
this.nativeViewProtected.accessibilityElementsHidden = !!value;
updateAccessibilityProperties(this);
}
[accessibilityLiveRegionProperty.setNative](): void {
updateAccessibilityProperties(this);
}
[accessibilityStateProperty.setNative](): void {
updateAccessibilityProperties(this);
}
[accessibilityMediaSessionProperty.setNative](): void {
updateAccessibilityProperties(this);
}
[isUserInteractionEnabledProperty.getDefault](): boolean {
return this.nativeViewProtected.userInteractionEnabled;
}
@ -673,6 +732,54 @@ export class View extends ViewCommon implements ViewDefinition {
}
}
public iosPostAccessibilityNotification(notificationType: IOSPostAccessibilityNotificationType, msg?: string): void {
if (!notificationType) {
return;
}
let notification: number;
let args: string | UIView | null = this.nativeViewProtected;
if (typeof msg === 'string' && msg) {
args = msg;
}
switch (notificationType) {
case IOSPostAccessibilityNotificationType.Announcement: {
notification = UIAccessibilityAnnouncementNotification;
break;
}
case IOSPostAccessibilityNotificationType.Layout: {
notification = UIAccessibilityLayoutChangedNotification;
break;
}
case IOSPostAccessibilityNotificationType.Screen: {
notification = UIAccessibilityScreenChangedNotification;
break;
}
default: {
return;
}
}
UIAccessibilityPostNotification(notification, args ?? null);
}
public accessibilityAnnouncement(msg = this.accessibilityLabel): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
this.iosPostAccessibilityNotification(IOSPostAccessibilityNotificationType.Announcement, msg);
}
public accessibilityScreenChanged(): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
this.iosPostAccessibilityNotification(IOSPostAccessibilityNotificationType.Screen);
}
_getCurrentLayoutBounds(): {
left: number;
top: number;

View File

@ -22,6 +22,9 @@ import { LinearGradient } from '../../styling/linear-gradient';
import { TextTransform } from '../../text-base';
import * as am from '../../animation';
import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait, AndroidAccessibilityEvent, IOSPostAccessibilityNotificationType } from '../../../accessibility/accessibility-types';
import { accessibilityHintProperty, accessibilityIdentifierProperty, accessibilityLabelProperty, accessibilityTraitsProperty, accessibilityValueProperty } from '../../../accessibility/accessibility-properties';
import { accessibilityBlurEvent, accessibilityFocusChangedEvent, accessibilityFocusEvent, getCurrentFontScale } from '../../../accessibility';
import { BoxShadow } from '../../styling/box-shadow';
// helpers (these are okay re-exported here)
@ -68,6 +71,9 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
public static layoutChangedEvent = 'layoutChanged';
public static shownModallyEvent = 'shownModally';
public static showingModallyEvent = 'showingModally';
public static accessibilityBlurEvent = accessibilityBlurEvent;
public static accessibilityFocusEvent = accessibilityFocusEvent;
public static accessibilityFocusChangedEvent = accessibilityFocusChangedEvent;
protected _closeModalCallback: Function;
public _manager: any;
@ -91,6 +97,8 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
public _gestureObservers = {};
_androidContentDescriptionUpdated?: boolean;
get css(): string {
const scope = this._styleScope;
@ -360,6 +368,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
modalRootViewCssClasses.forEach((c) => this.cssClasses.add(c));
parent._modal = this;
this.style._fontScale = getCurrentFontScale();
this._modalParent = parent;
this._modalContext = options.context;
const that = this;
@ -743,6 +752,71 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
this.style.scaleY = value;
}
get accessible(): boolean {
return this.style.accessible;
}
set accessible(value: boolean) {
this.style.accessible = value;
}
get accessibilityHidden(): boolean {
return this.style.accessibilityHidden;
}
set accessibilityHidden(value: boolean) {
this.style.accessibilityHidden = value;
}
public accessibilityIdentifier: string;
get accessibilityRole(): AccessibilityRole {
return this.style.accessibilityRole;
}
set accessibilityRole(value: AccessibilityRole) {
this.style.accessibilityRole = value;
}
get accessibilityState(): AccessibilityState {
return this.style.accessibilityState;
}
set accessibilityState(value: AccessibilityState) {
this.style.accessibilityState = value;
}
public accessibilityLabel: string;
public accessibilityValue: string;
public accessibilityHint: string;
get accessibilityLiveRegion(): AccessibilityLiveRegion {
return this.style.accessibilityLiveRegion;
}
set accessibilityLiveRegion(value: AccessibilityLiveRegion) {
this.style.accessibilityLiveRegion = value;
}
get accessibilityLanguage(): string {
return this.style.accessibilityLanguage;
}
set accessibilityLanguage(value: string) {
this.style.accessibilityLanguage = value;
}
get accessibilityMediaSession(): boolean {
return this.style.accessibilityMediaSession;
}
set accessibilityMediaSession(value: boolean) {
this.style.accessibilityMediaSession = value;
}
public accessibilityTraits?: AccessibilityTrait[];
get automationText(): string {
return this.accessibilityIdentifier;
}
set automationText(value: string) {
this.accessibilityIdentifier = value;
}
get androidElevation(): number {
return this.style.androidElevation;
}
@ -759,7 +833,6 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
//END Style property shortcuts
public automationText: string;
public originX: number;
public originY: number;
public isEnabled: boolean;
@ -1013,12 +1086,23 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
return false;
}
}
export const automationTextProperty = new Property<ViewCommon, string>({
name: 'automationText',
});
automationTextProperty.register(ViewCommon);
public androidSendAccessibilityEvent(eventName: AndroidAccessibilityEvent, msg?: string): void {
return;
}
public iosPostAccessibilityNotification(notificationType: IOSPostAccessibilityNotificationType, msg?: string): void {
return;
}
public accessibilityAnnouncement(msg?: string): void {
return;
}
public accessibilityScreenChanged(): void {
return;
}
}
export const originXProperty = new Property<ViewCommon, number>({
name: 'originX',
@ -1070,3 +1154,9 @@ export const iosIgnoreSafeAreaProperty = new InheritedProperty({
valueConverter: booleanConverter,
});
iosIgnoreSafeAreaProperty.register(ViewCommon);
accessibilityIdentifierProperty.register(ViewCommon);
accessibilityLabelProperty.register(ViewCommon);
accessibilityValueProperty.register(ViewCommon);
accessibilityHintProperty.register(ViewCommon);
accessibilityTraitsProperty.register(ViewCommon);

View File

@ -5,6 +5,7 @@ import { ActionBar } from '../action-bar';
import { GridLayout } from '../layouts/grid-layout';
import { Device } from '../../platform';
import { profile } from '../../profiling';
import { AndroidAccessibilityEvent, getLastFocusedViewOnPage, isAccessibilityServiceEnabled } from '../../accessibility';
export * from './page-common';
@ -122,4 +123,31 @@ export class Page extends PageBase {
(<any>window).setStatusBarColor(color);
}
}
public accessibilityScreenChanged(refocus = false): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
if (refocus) {
const lastFocusedView = getLastFocusedViewOnPage(this);
if (lastFocusedView) {
const announceView = lastFocusedView.nativeViewProtected;
if (announceView) {
announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED);
announceView.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
return;
}
}
}
if (this.actionBarHidden || this.accessibilityLabel) {
this.androidSendAccessibilityEvent(AndroidAccessibilityEvent.WINDOW_STATE_CHANGED);
return;
}
this.actionBar.accessibilityScreenChanged();
}
}

View File

@ -93,6 +93,11 @@ export declare class Page extends PageBase {
*/
public actionBar: ActionBar;
/**
* Should page changed be annnounced to the screen reader.
*/
public accessibilityAnnouncePageEnabled = true;
/**
* A basic method signature to hook an event listener (shortcut alias to the addEventListener method).
* @param eventNames - String corresponding to events (e.g. "propertyChange"). Optionally could be used more events separated by `,` (e.g. "propertyChange", "change").
@ -161,6 +166,11 @@ export declare class Page extends PageBase {
*/
public onNavigatedFrom(isBackNavigation: boolean): void;
//@endprivate
/**
* Announce screen changed
*/
public accessibilityScreenChanged(refocus?: boolean): void;
}
/**

View File

@ -7,6 +7,7 @@ import { PageBase, actionBarHiddenProperty, statusBarStyleProperty } from './pag
import { profile } from '../../profiling';
import { iOSNativeHelper, layout } from '../../utils';
import { getLastFocusedViewOnPage, isAccessibilityServiceEnabled } from '../../accessibility';
export * from './page-common';
@ -522,6 +523,44 @@ export class Page extends PageBase {
}
}
}
public accessibilityScreenChanged(refocus = false): void {
if (!isAccessibilityServiceEnabled()) {
return;
}
if (refocus) {
const lastFocusedView = getLastFocusedViewOnPage(this);
if (lastFocusedView) {
const uiView = lastFocusedView.nativeViewProtected;
if (uiView) {
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, uiView);
return;
}
}
}
if (this.actionBarHidden) {
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.nativeViewProtected);
return;
}
if (this.accessibilityLabel) {
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.nativeViewProtected);
return;
}
if (this.actionBar.accessibilityLabel || this.actionBar.title) {
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.actionBar.nativeView);
return;
}
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, this.nativeViewProtected);
}
}
function invalidateTopmostController(controller: UIViewController): void {

View File

@ -33,6 +33,7 @@ export class PageBase extends ContentView {
public enableSwipeBackNavigation: boolean;
public backgroundSpanUnderStatusBar: boolean;
public hasActionBar: boolean;
public accessibilityAnnouncePageEnabled = true;
get navigationContext(): any {
return this._navigationContext;
@ -126,8 +127,12 @@ export class PageBase extends ContentView {
}
@profile
public onNavigatedTo(isBackNavigation: boolean) {
public onNavigatedTo(isBackNavigation: boolean): void {
this.notify(this.createNavigatedData(PageBase.navigatedToEvent, isBackNavigation));
if (this.accessibilityAnnouncePageEnabled) {
this.accessibilityScreenChanged(!!isBackNavigation);
}
}
@profile
@ -152,6 +157,10 @@ export class PageBase extends ContentView {
get _childrenCount(): number {
return (this.content ? 1 : 0) + (this._actionBar ? 1 : 0);
}
public accessibilityScreenChanged(refocus?: boolean): void {
return;
}
}
PageBase.prototype.recycleNativeView = 'never';

View File

@ -1,10 +1,14 @@
import { View } from '../core/view';
import { Property, CoercibleProperty } from '../core/properties';
import { EventData } from '../../data/observable';
/**
* Represents a slider component.
*/
export class Slider extends View {
static readonly accessibilityDecrementEvent = 'accessibilityDecrement';
static readonly accessibilityIncrementEvent = 'accessibilityIncrement';
/**
* Gets the native [android widget](http://developer.android.com/reference/android/widget/SeekBar.html) that represents the user interface for this component. Valid only when running on Android OS.
*/
@ -29,6 +33,11 @@ export class Slider extends View {
* Gets or sets a slider max value. The default value is 100.
*/
maxValue: number;
/**
* Increase/Decrease step size for iOS Increment-/Decrement events
*/
accessibilityStep: number;
}
/**
@ -45,3 +54,18 @@ export const minValueProperty: Property<Slider, number>;
* Represents the observable property backing the maxValue property of each Slider instance.
*/
export const maxValueProperty: CoercibleProperty<Slider, number>;
/**
* Represents the observable property backing the accessibilityStep property of each Slider instance.
*/
export const accessibilityStepProperty: Property<SliderBase, number>;
interface AccessibilityIncrementEventData extends EventData {
object: Slider;
value?: number;
}
interface AccessibilityDecrementEventData extends EventData {
object: Slider;
value?: number;
}

View File

@ -3,9 +3,44 @@ import { Background } from '../styling/background';
import { SliderBase, valueProperty, minValueProperty, maxValueProperty } from './slider-common';
import { colorProperty, backgroundColorProperty, backgroundInternalProperty } from '../styling/style-properties';
import { Color } from '../../color';
import { AccessibilityDecrementEventData, AccessibilityIncrementEventData } from '.';
export * from './slider-common';
@NativeClass()
class TNSSlider extends UISlider {
public owner: WeakRef<Slider>;
public static initWithOwner(owner: WeakRef<Slider>) {
const slider = TNSSlider.new() as TNSSlider;
slider.owner = owner;
return slider;
}
public accessibilityIncrement() {
const owner = this.owner.get();
if (!owner) {
this.value += 10;
} else {
this.value = owner._handlerAccessibilityIncrementEvent();
}
this.sendActionsForControlEvents(UIControlEvents.ValueChanged);
}
public accessibilityDecrement() {
const owner = this.owner.get();
if (!owner) {
this.value += 10;
} else {
this.value = owner._handlerAccessibilityDecrementEvent();
}
this.sendActionsForControlEvents(UIControlEvents.ValueChanged);
}
}
@NativeClass
class SliderChangeHandlerImpl extends NSObject {
private _owner: WeakRef<Slider>;
@ -30,11 +65,11 @@ class SliderChangeHandlerImpl extends NSObject {
}
export class Slider extends SliderBase {
nativeViewProtected: UISlider;
nativeViewProtected: TNSSlider;
private _changeHandler: NSObject;
public createNativeView() {
return UISlider.new();
public createNativeView(): TNSSlider {
return TNSSlider.initWithOwner(new WeakRef(this));
}
public initNativeView(): void {
@ -47,7 +82,7 @@ export class Slider extends SliderBase {
nativeView.addTargetActionForControlEvents(this._changeHandler, 'sliderValueChanged', UIControlEvents.ValueChanged);
}
public disposeNativeView() {
public disposeNativeView(): void {
this._changeHandler = null;
super.disposeNativeView();
}
@ -98,4 +133,36 @@ export class Slider extends SliderBase {
[backgroundInternalProperty.setNative](value: Background) {
//
}
private getAccessibilityStep(): number {
if (!this.accessibilityStep || this.accessibilityStep <= 0) {
return 10;
}
return this.accessibilityStep;
}
public _handlerAccessibilityIncrementEvent(): number {
const args: AccessibilityIncrementEventData = {
object: this,
eventName: SliderBase.accessibilityIncrementEvent,
value: this.value + this.getAccessibilityStep(),
};
this.notify(args);
return args.value;
}
public _handlerAccessibilityDecrementEvent(): number {
const args: AccessibilityDecrementEventData = {
object: this,
eventName: SliderBase.accessibilityIncrementEvent,
value: this.value - this.getAccessibilityStep(),
};
this.notify(args);
return args.value;
}
}

View File

@ -1,13 +1,28 @@
import { Slider as SliderDefinition } from '.';
import { View, CSSType } from '../core/view';
import { Property, CoercibleProperty } from '../core/properties';
import { AccessibilityRole } from '../../accessibility';
import { CoercibleProperty, Property } from '../core/properties';
import { CSSType, View } from '../core/view';
// TODO: Extract base Range class for slider and progress
@CSSType('Slider')
export class SliderBase extends View implements SliderDefinition {
static readonly accessibilityIncrementEvent = 'accessibilityIncrement';
static readonly accessibilityDecrementEvent = 'accessibilityDecrement';
public value: number;
public minValue: number;
public maxValue: number;
get accessibilityStep(): number {
return this.style.accessibilityStep;
}
set accessibilityStep(value: number) {
this.style.accessibilityStep = value;
}
accessible = true;
accessibilityRole = AccessibilityRole.Adjustable;
}
SliderBase.prototype.recycleNativeView = 'auto';

View File

@ -15,7 +15,7 @@ export abstract class Font implements FontDefinition {
return this.fontWeight === FontWeight.SEMI_BOLD || this.fontWeight === FontWeight.BOLD || this.fontWeight === '700' || this.fontWeight === FontWeight.EXTRA_BOLD || this.fontWeight === FontWeight.BLACK;
}
protected constructor(public readonly fontFamily: string, public readonly fontSize: number, public readonly fontStyle: FontStyle, public readonly fontWeight: FontWeight) {}
protected constructor(public readonly fontFamily: string, public readonly fontSize: number, public readonly fontStyle: FontStyle, public readonly fontWeight: FontWeight, public readonly fontScale: number) {}
public abstract getAndroidTypeface(): any /* android.graphics.Typeface */;
public abstract getUIFont(defaultFont: any /* UIFont */): any /* UIFont */;
@ -23,6 +23,7 @@ export abstract class Font implements FontDefinition {
public abstract withFontStyle(style: string): Font;
public abstract withFontWeight(weight: string): Font;
public abstract withFontSize(size: number): Font;
public abstract withFontScale(scale: number): Font;
public static equals(value1: Font, value2: Font): boolean {
// both values are falsy

View File

@ -15,7 +15,7 @@ export class Font extends FontBase {
private _typeface: android.graphics.Typeface;
constructor(family: string, size: number, style: 'normal' | 'italic', weight: FontWeight) {
super(family, size, style, weight);
super(family, size, style, weight, 1);
}
public withFontFamily(family: string): Font {
@ -34,6 +34,10 @@ export class Font extends FontBase {
return new Font(this.fontFamily, size, this.fontStyle, this.fontWeight);
}
public withFontScale(scale: number): Font {
return new Font(this.fontFamily, this.fontSize, this.fontStyle, this.fontWeight);
}
public getAndroidTypeface(): android.graphics.Typeface {
if (!this._typeface) {
this._typeface = createTypeface(this);

View File

@ -5,6 +5,7 @@
public fontStyle: FontStyle;
public fontWeight: FontWeight;
public fontSize: number;
public fontScale: number;
public isBold: boolean;
public isItalic: boolean;
@ -18,6 +19,7 @@
public withFontStyle(style: string): Font;
public withFontWeight(weight: string): Font;
public withFontSize(size: number): Font;
public withFontScale(scale: number): Font;
public static equals(value1: Font, value2: Font): boolean;
}

View File

@ -11,28 +11,32 @@ const DEFAULT_MONOSPACE = 'Courier New';
const SUPPORT_FONT_WEIGHTS = parseFloat(Device.osVersion) >= 10.0;
export class Font extends FontBase {
public static default = new Font(undefined, undefined, FontStyle.NORMAL, FontWeight.NORMAL);
public static default = new Font(undefined, undefined, FontStyle.NORMAL, FontWeight.NORMAL, 1);
private _uiFont: UIFont;
constructor(family: string, size: number, style: FontStyle, weight: FontWeight) {
super(family, size, style, weight);
constructor(family: string, size: number, style: FontStyle, weight: FontWeight, scale: number) {
super(family, size, style, weight, scale);
}
public withFontFamily(family: string): Font {
return new Font(family, this.fontSize, this.fontStyle, this.fontWeight);
return new Font(family, this.fontSize, this.fontStyle, this.fontWeight, this.fontScale);
}
public withFontStyle(style: FontStyle): Font {
return new Font(this.fontFamily, this.fontSize, style, this.fontWeight);
return new Font(this.fontFamily, this.fontSize, style, this.fontWeight, this.fontScale);
}
public withFontWeight(weight: FontWeight): Font {
return new Font(this.fontFamily, this.fontSize, this.fontStyle, weight);
return new Font(this.fontFamily, this.fontSize, this.fontStyle, weight, this.fontScale);
}
public withFontSize(size: number): Font {
return new Font(this.fontFamily, size, this.fontStyle, this.fontWeight);
return new Font(this.fontFamily, size, this.fontStyle, this.fontWeight, this.fontScale);
}
public withFontScale(scale: number): Font {
return new Font(this.fontFamily, this.fontSize, this.fontStyle, this.fontWeight, scale);
}
public getUIFont(defaultFont: UIFont): UIFont {

View File

@ -1422,6 +1422,27 @@ export const fontFamilyProperty = new InheritedCssProperty<Style, string>({
});
fontFamilyProperty.register(Style);
export const fontScaleProperty = new InheritedCssProperty<Style, number>({
name: '_fontScale',
cssName: '_fontScale',
affectsLayout: global.isIOS,
valueChanged: (target, oldValue, newValue) => {
if (global.isIOS) {
if (target.viewRef['handleFontSize'] === true) {
return;
}
const currentFont = target.fontInternal || Font.default;
if (currentFont.fontScale !== newValue) {
const newFont = currentFont.withFontScale(newValue);
target.fontInternal = Font.equals(Font.default, newFont) ? unsetValue : newFont;
}
}
},
valueConverter: (v) => parseFloat(v),
});
fontScaleProperty.register(Style);
export const fontSizeProperty = new InheritedCssProperty<Style, number>({
name: 'fontSize',
cssName: 'font-size',

View File

@ -11,6 +11,7 @@ import { Observable } from '../../../data/observable';
import { FlexDirection, FlexWrap, JustifyContent, AlignItems, AlignContent, Order, FlexGrow, FlexShrink, FlexWrapBefore, AlignSelf } from '../../layouts/flexbox-layout';
import { Trace } from '../../../trace';
import { TextAlignment, TextDecoration, TextTransform, WhiteSpace, TextShadow } from '../../text-base';
import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState } from '../../../accessibility/accessibility-types';
import { BoxShadow } from '../box-shadow';
export interface CommonLayoutParams {
@ -98,6 +99,7 @@ export class Style extends Observable implements StyleDefinition {
}
public fontInternal: Font;
public _fontScale: number;
public backgroundInternal: Background;
public rotate: number;
@ -211,6 +213,16 @@ export class Style extends Observable implements StyleDefinition {
public flexWrapBefore: FlexWrapBefore;
public alignSelf: AlignSelf;
// Accessibility properties
public accessible: boolean;
public accessibilityHidden: boolean;
public accessibilityRole: AccessibilityRole;
public accessibilityState: AccessibilityState;
public accessibilityLiveRegion: AccessibilityLiveRegion;
public accessibilityLanguage: string;
public accessibilityMediaSession: boolean;
public accessibilityStep: number;
public PropertyBag: {
new (): { [property: string]: string };
prototype: { [property: string]: string };

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item type="id" name="nativescript_accessibility_id"/>
</resources>