Highlight the current talkback-focused element in the accessibility tree

Summary: Highlights the element corresponding to the view talkback is focused on in green in the ax tree (and updates live as talkback moves).

Reviewed By: blavalla

Differential Revision: D9021542

fbshipit-source-id: c3bf6f5625aacb0cd054032b33a50541b88b2eaf
This commit is contained in:
Sara Valderrama
2018-07-27 16:16:54 -07:00
committed by Facebook Github Bot
parent 6939292209
commit 33e6538477
10 changed files with 154 additions and 18 deletions

View File

@@ -10,6 +10,8 @@ package com.facebook.sonar.plugins.inspector;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import android.support.v4.view.ViewCompat;
import android.view.accessibility.AccessibilityEvent;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@@ -170,20 +172,41 @@ public class InspectorSonarPlugin implements SonarPlugin {
@Override @Override
public void onReceiveOnMainThread(SonarObject params, SonarResponder responder) public void onReceiveOnMainThread(SonarObject params, SonarResponder responder)
throws Exception { throws Exception {
List<View> viewRoots = mApplication.getViewRoots(); final List<View> viewRoots = mApplication.getViewRoots();
// for now only works if one view root
if (viewRoots.size() != 1) { ViewGroup root = null;
responder.error( for (int i = viewRoots.size() - 1; i >= 0; i--) {
new SonarObject.Builder().put("message", "Too many view roots.").build()); if (viewRoots.get(i) instanceof ViewGroup) {
return; root = (ViewGroup) viewRoots.get(i);
break;
}
} }
SonarObject response = getAXNode(trackObject(viewRoots.get(0)));
if (response == null) { if (root != null) {
responder.error(
new SonarObject.Builder().put("message", "AX root node returned null.").build()); // unlikely, but check to make sure accessibility functionality doesn't change
return; if (!ViewCompat.hasAccessibilityDelegate(root)) {
// add delegate to root to catch accessibility events so we can update focus in sonar
root.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@Override
public boolean onRequestSendAccessibilityEvent(ViewGroup host, View child, AccessibilityEvent event) {
int eventType = event.getEventType();
if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED || eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED) {
mConnection.send("axFocusEvent",
new SonarObject.Builder()
.put("isFocus", eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
.build());
}
return super.onRequestSendAccessibilityEvent(host, child, event);
}
});
}
responder.success(getAXNode(trackObject(root)));
} }
responder.success(response);
} }
}; };
@@ -550,6 +573,7 @@ public class InspectorSonarPlugin implements SonarPlugin {
.put("data", data) .put("data", data)
.put("children", children) .put("children", children)
.put("attributes", attributes) .put("attributes", attributes)
.put("extraInfo", descriptor.getExtraInfo(obj))
.build(); .build();
} }

View File

@@ -131,6 +131,12 @@ public class TextViewDescriptor extends NodeDescriptor<TextView> {
return descriptor.getAXAttributes(node); return descriptor.getAXAttributes(node);
} }
@Override
public SonarObject getExtraInfo(TextView node) {
final NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getExtraInfo(node);
}
@Override @Override
public void setHighlighted(TextView node, boolean selected) throws Exception { public void setHighlighted(TextView node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class); final NodeDescriptor descriptor = descriptorForClass(View.class);

View File

@@ -444,6 +444,7 @@ public class ViewDescriptor extends NodeDescriptor<View> {
return attributes; return attributes;
} }
@Override
public List<Named<String>> getAXAttributes(View node) throws Exception { public List<Named<String>> getAXAttributes(View node) throws Exception {
List<Named<String>> attributes = new ArrayList<>(); List<Named<String>> attributes = new ArrayList<>();
String role = AccessibilityRoleUtil.getRole(node).toString(); String role = AccessibilityRoleUtil.getRole(node).toString();
@@ -453,6 +454,11 @@ public class ViewDescriptor extends NodeDescriptor<View> {
return attributes; return attributes;
} }
@Override
public SonarObject getExtraInfo(View node) {
return new SonarObject.Builder().put("focused", AccessibilityUtil.isAXFocused(node)).build();
}
@Nullable @Nullable
private static String getResourceId(View node) { private static String getResourceId(View node) {
final int id = node.getId(); final int id = node.getId();

View File

@@ -235,6 +235,12 @@ public class ViewGroupDescriptor extends NodeDescriptor<ViewGroup> {
return descriptor.getAXAttributes(node); return descriptor.getAXAttributes(node);
} }
@Override
public SonarObject getExtraInfo(ViewGroup node) {
final NodeDescriptor descriptor = descriptorForClass(View.class);
return descriptor.getExtraInfo(node);
}
@Override @Override
public void setHighlighted(ViewGroup node, boolean selected) throws Exception { public void setHighlighted(ViewGroup node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(View.class); final NodeDescriptor descriptor = descriptorForClass(View.class);

View File

@@ -420,6 +420,18 @@ public final class AccessibilityUtil {
return nodeInfoProps.build(); return nodeInfoProps.build();
} }
public static boolean isAXFocused(View view) {
final AccessibilityNodeInfoCompat nodeInfo =
ViewAccessibilityHelper.createNodeInfoFromView(view);
if (nodeInfo == null) {
return false;
} else {
boolean focused = nodeInfo.isAccessibilityFocused();
nodeInfo.recycle();
return focused;
}
}
/** /**
* Modifies a {@link SonarObject.Builder} to add Talkback-specific Accessibiltiy properties to be * Modifies a {@link SonarObject.Builder} to add Talkback-specific Accessibiltiy properties to be
* shown in the Sonar Layout Inspector. * shown in the Sonar Layout Inspector.

View File

@@ -117,6 +117,12 @@ public class LithoViewDescriptor extends NodeDescriptor<LithoView> {
return descriptor.getAXAttributes(node); return descriptor.getAXAttributes(node);
} }
@Override
public SonarObject getExtraInfo(LithoView node) {
final NodeDescriptor descriptor = descriptorForClass(ViewGroup.class);
return descriptor.getExtraInfo(node);
}
@Override @Override
public void setHighlighted(LithoView node, boolean selected) throws Exception { public void setHighlighted(LithoView node, boolean selected) throws Exception {
final NodeDescriptor descriptor = descriptorForClass(ViewGroup.class); final NodeDescriptor descriptor = descriptorForClass(ViewGroup.class);

View File

@@ -14,6 +14,7 @@ export class AXElementsInspector extends Component<{
onElementHovered: ?(key: ?ElementID) => void, onElementHovered: ?(key: ?ElementID) => void,
onValueChanged: ?(path: Array<string>, val: any) => void, onValueChanged: ?(path: Array<string>, val: any) => void,
selected: ?ElementID, selected: ?ElementID,
focused: ?ElementID,
searchResults?: ?ElementSearchResultSet, searchResults?: ?ElementSearchResultSet,
root: ?ElementID, root: ?ElementID,
elements: {[key: ElementID]: Element}, elements: {[key: ElementID]: Element},

View File

@@ -38,6 +38,7 @@ export type InspectorState = {|
AXinitialised: boolean, AXinitialised: boolean,
selected: ?ElementID, selected: ?ElementID,
AXselected: ?ElementID, AXselected: ?ElementID,
AXfocused: ?ElementID,
root: ?ElementID, root: ?ElementID,
AXroot: ?ElementID, AXroot: ?ElementID,
elements: {[key: ElementID]: Element}, elements: {[key: ElementID]: Element},
@@ -66,6 +67,15 @@ type UpdateElementsArgs = {|
elements: Array<$Shape<Element>>, elements: Array<$Shape<Element>>,
|}; |};
type UpdateAXElementsArgs = {|
elements: Array<$Shape<Element>>,
forFocusEvent: boolean,
|};
type AXFocusEventResult = {|
isFocus: boolean,
|};
type SetRootArgs = {| type SetRootArgs = {|
root: ElementID, root: ElementID,
|}; |};
@@ -159,6 +169,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
AXroot: null, AXroot: null,
selected: null, selected: null,
AXselected: null, AXselected: null,
AXfocused: null,
searchResults: null, searchResults: null,
outstandingSearchQuery: null, outstandingSearchQuery: null,
AXtoNonAXMapping: {}, AXtoNonAXMapping: {},
@@ -278,17 +289,30 @@ export default class Layout extends SonarPlugin<InspectorState> {
return {elements: updatedElements, AXtoNonAXMapping: updatedMapping}; return {elements: updatedElements, AXtoNonAXMapping: updatedMapping};
}, },
UpdateAXElements(state: InspectorState, {elements}: UpdateElementsArgs) { UpdateAXElements(
state: InspectorState,
{elements, forFocusEvent}: UpdateAXElementsArgs,
) {
const updatedElements = state.AXelements; const updatedElements = state.AXelements;
// if focusEvent, previously focused element can be reset
let updatedFocus = forFocusEvent ? null : state.AXfocused;
for (const element of elements) { for (const element of elements) {
if (element.extraInfo.focused) {
updatedFocus = element.id;
}
const current = updatedElements[element.id] || {}; const current = updatedElements[element.id] || {};
updatedElements[element.id] = { updatedElements[element.id] = {
...current, ...current,
...element, ...element,
}; };
} }
return {AXelements: updatedElements};
return {
AXelements: updatedElements,
AXfocused: updatedFocus,
};
}, },
SetRoot(state: InspectorState, {root}: SetRootArgs) { SetRoot(state: InspectorState, {root}: SetRootArgs) {
@@ -442,6 +466,34 @@ export default class Layout extends SonarPlugin<InspectorState> {
}); });
} }
this.client.subscribe('axFocusEvent', (focusEvent: AXFocusEventResult) => {
if (AXToggleButtonEnabled) {
// if focusing, need to update all elements in the tree because
// we don't know which one now has focus
const keys = focusEvent.isFocus
? Object.keys(this.state.AXelements)
: [];
// if unfocusing and currently focused element exists, update only the
// focused element (and only if it is loaded in tree)
if (
!focusEvent.isFocus &&
this.state.AXfocused &&
this.state.AXelements[this.state.AXfocused]
) {
keys.push(this.state.AXfocused);
}
this.getNodes(keys, true, true).then((elements: Array<Element>) => {
this.dispatchAction({
elements,
forFocusEvent: true,
type: 'UpdateAXElements',
});
});
}
});
this.client.subscribe( this.client.subscribe(
'invalidate', 'invalidate',
({nodes}: {nodes: Array<{id: ElementID}>}) => { ({nodes}: {nodes: Array<{id: ElementID}>}) => {
@@ -713,6 +765,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
AXinitialised, AXinitialised,
selected, selected,
AXselected, AXselected,
AXfocused,
root, root,
AXroot, AXroot,
elements, elements,
@@ -792,6 +845,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
onElementExpanded={this.onElementExpanded} onElementExpanded={this.onElementExpanded}
onValueChanged={this.onDataValueChanged} onValueChanged={this.onDataValueChanged}
selected={AXselected} selected={AXselected}
focused={AXfocused}
searchResults={null} searchResults={null}
root={AXroot} root={AXroot}
elements={AXelements} elements={AXelements}

View File

@@ -37,6 +37,7 @@ export type ElementAttribute = {|
export type ElementExtraInfo = {| export type ElementExtraInfo = {|
nonAXWithAXChild?: boolean, nonAXWithAXChild?: boolean,
linkedAXNode?: string, linkedAXNode?: string,
focused?: boolean,
|}; |};
export type Element = {| export type Element = {|

View File

@@ -31,6 +31,8 @@ const ElementsRowContainer = ContextMenu.extends(
backgroundColor: props => { backgroundColor: props => {
if (props.selected) { if (props.selected) {
return colors.macOSTitleBarIconSelected; return colors.macOSTitleBarIconSelected;
} else if (props.focused) {
return colors.lime;
} else if (props.even) { } else if (props.even) {
return colors.light02; return colors.light02;
} else { } else {
@@ -47,12 +49,20 @@ const ElementsRowContainer = ContextMenu.extends(
position: 'relative', position: 'relative',
'& *': { '& *': {
color: props => (props.selected ? `${colors.white} !important` : ''), color: props =>
props.selected || props.focused ? `${colors.white} !important` : '',
}, },
'&:hover': { '&:hover': {
backgroundColor: props => backgroundColor: props => {
props.selected ? colors.macOSTitleBarIconSelected : '#EBF1FB', if (props.selected) {
return colors.macOSTitleBarIconSelected;
} else if (props.focused) {
return colors.lime;
} else {
return '#EBF1FB';
}
},
}, },
}, },
{ {
@@ -122,7 +132,9 @@ class PartialHighlight extends PureComponent<{
static HighlightedText = styled.text({ static HighlightedText = styled.text({
backgroundColor: '#ffff33', backgroundColor: '#ffff33',
color: props => color: props =>
props.selected ? `${colors.grapeDark3} !important` : 'auto', props.selected || props.focused
? `${colors.grapeDark3} !important`
: 'auto',
}); });
render() { render() {
@@ -162,6 +174,7 @@ class ElementsRowAttribute extends PureComponent<{
value: string, value: string,
matchingSearchQuery: ?string, matchingSearchQuery: ?string,
selected: boolean, selected: boolean,
focused: boolean,
}> { }> {
render() { render() {
const {name, value, matchingSearchQuery, selected} = this.props; const {name, value, matchingSearchQuery, selected} = this.props;
@@ -195,6 +208,7 @@ type ElementsRowProps = {
id: ElementID, id: ElementID,
level: number, level: number,
selected: boolean, selected: boolean,
focused: boolean,
matchingSearchQuery: ?string, matchingSearchQuery: ?string,
element: Element, element: Element,
even: boolean, even: boolean,
@@ -268,6 +282,7 @@ class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
id, id,
level, level,
selected, selected,
focused,
style, style,
even, even,
matchingSearchQuery, matchingSearchQuery,
@@ -295,6 +310,7 @@ class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
value={attr.value} value={attr.value}
matchingSearchQuery={matchingSearchQuery} matchingSearchQuery={matchingSearchQuery}
selected={selected} selected={selected}
focused={focused}
/> />
)) ))
: []; : [];
@@ -327,6 +343,7 @@ class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
key={id} key={id}
level={level} level={level}
selected={selected} selected={selected}
focused={focused}
matchingSearchQuery={matchingSearchQuery} matchingSearchQuery={matchingSearchQuery}
even={even} even={even}
onClick={this.onClick} onClick={this.onClick}
@@ -368,6 +385,7 @@ const ElementsBox = FlexColumn.extends({
type ElementsProps = {| type ElementsProps = {|
root: ?ElementID, root: ?ElementID,
selected: ?ElementID, selected: ?ElementID,
focused?: ?ElementID,
searchResults: ?ElementSearchResultSet, searchResults: ?ElementSearchResultSet,
elements: {[key: ElementID]: Element}, elements: {[key: ElementID]: Element},
onElementSelected: (key: ElementID) => void, onElementSelected: (key: ElementID) => void,
@@ -532,6 +550,7 @@ export class Elements extends PureComponent<ElementsProps, ElementsState> {
onElementHovered, onElementHovered,
onElementSelected, onElementSelected,
selected, selected,
focused,
searchResults, searchResults,
} = this.props; } = this.props;
const {flatElements} = this.state; const {flatElements} = this.state;
@@ -557,6 +576,7 @@ export class Elements extends PureComponent<ElementsProps, ElementsState> {
onElementHovered={onElementHovered} onElementHovered={onElementHovered}
onElementSelected={onElementSelected} onElementSelected={onElementSelected}
selected={selected === row.key} selected={selected === row.key}
focused={focused === row.key}
matchingSearchQuery={ matchingSearchQuery={
searchResults && searchResults.matches.has(row.key) searchResults && searchResults.matches.has(row.key)
? searchResults.query ? searchResults.query