Skip to content

Commit 566e100

Browse files
fix(android): correct accessibility focus order in inverted FlatList (RocketChat#6934)
* patch package to not invert the a11y order * feat: a11yInvertedView manager * fix(a11y): Fix accessibility traversal order for inverted FlatList on Android * fix: use the default a11y behavior * fix: build * lock * timeout * feat: created flatlist inverted native module * remove previous solution * fix: revert timeout build android * fix: list components style * patch-package * revert package.json updates * revert package.json updates * chore: format code and fix lint issues [skip ci] * code improvements * code improvements * chore: format code and fix lint issues [skip ci] * update comments * use foward ref * use FowardRef * fix: invertedScrollView ref * cleanup * chore: format code and fix lint issues [skip ci] * fix: invertedScrollView * code improvements * chore: format code and fix lint issues [skip ci] * chore: remove isAndroid verification --------- Co-authored-by: OtavioStasiak <OtavioStasiak@users.noreply.github.com>
1 parent 57137f3 commit 566e100

File tree

8 files changed

+295
-0
lines changed

8 files changed

+295
-0
lines changed

android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import chat.rocket.reactnative.storage.MMKVKeyManager;
2020
import chat.rocket.reactnative.storage.SecureStoragePackage;
2121
import chat.rocket.reactnative.notification.VideoConfTurboPackage
2222
import chat.rocket.reactnative.notification.PushNotificationTurboPackage
23+
import chat.rocket.reactnative.scroll.InvertedScrollPackage
2324

2425
/**
2526
* Main Application class.
@@ -44,6 +45,7 @@ open class MainApplication : Application(), ReactApplication {
4445
add(VideoConfTurboPackage())
4546
add(PushNotificationTurboPackage())
4647
add(SecureStoragePackage())
48+
add(InvertedScrollPackage())
4749
}
4850

4951
override fun getJSMainModuleName(): String = "index"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package chat.rocket.reactnative.scroll;
2+
3+
import android.view.View;
4+
import com.facebook.react.views.view.ReactViewGroup;
5+
import java.util.ArrayList;
6+
import java.util.Collections;
7+
8+
/**
9+
* Content view for inverted FlatLists. Reports its children to accessibility in reversed order so
10+
* TalkBack traversal matches the visual order (newest-first) when used inside InvertedScrollView.
11+
*/
12+
public class InvertedScrollContentView extends ReactViewGroup {
13+
14+
public InvertedScrollContentView(android.content.Context context) {
15+
super(context);
16+
}
17+
18+
@Override
19+
public void addChildrenForAccessibility(ArrayList<View> outChildren) {
20+
super.addChildrenForAccessibility(outChildren);
21+
Collections.reverse(outChildren);
22+
}
23+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package chat.rocket.reactnative.scroll;
2+
3+
import com.facebook.react.module.annotations.ReactModule;
4+
import com.facebook.react.uimanager.ThemedReactContext;
5+
import com.facebook.react.views.view.ReactViewManager;
6+
7+
/**
8+
* View manager for InvertedScrollContentView. Behaves like a View but reports children in reversed
9+
* order for accessibility so TalkBack matches the visual order in inverted lists.
10+
*/
11+
@ReactModule(name = InvertedScrollContentViewManager.REACT_CLASS)
12+
public class InvertedScrollContentViewManager extends ReactViewManager {
13+
14+
public static final String REACT_CLASS = "InvertedScrollContentView";
15+
16+
@Override
17+
public String getName() {
18+
return REACT_CLASS;
19+
}
20+
21+
@Override
22+
public InvertedScrollContentView createViewInstance(ThemedReactContext context) {
23+
return new InvertedScrollContentView(context);
24+
}
25+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package chat.rocket.reactnative.scroll;
2+
3+
import com.facebook.react.ReactPackage;
4+
import com.facebook.react.bridge.NativeModule;
5+
import com.facebook.react.bridge.ReactApplicationContext;
6+
import com.facebook.react.uimanager.ViewManager;
7+
import java.util.Collections;
8+
import java.util.List;
9+
10+
public class InvertedScrollPackage implements ReactPackage {
11+
12+
@Override
13+
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
14+
return Collections.emptyList();
15+
}
16+
17+
@Override
18+
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
19+
List<ViewManager> managers = new java.util.ArrayList<>();
20+
managers.add(new InvertedScrollViewManager());
21+
managers.add(new InvertedScrollContentViewManager());
22+
return managers;
23+
}
24+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package chat.rocket.reactnative.scroll;
2+
3+
import android.view.View;
4+
import com.facebook.react.bridge.ReactContext;
5+
import com.facebook.react.views.scroll.ReactScrollView;
6+
import java.util.ArrayList;
7+
import java.util.Collections;
8+
9+
// When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform which
10+
// visually inverts the list but Android still reports children in array order. This view overrides
11+
// addChildrenForAccessibility to reverse the order so TalkBack matches the visual order.
12+
13+
public class InvertedScrollView extends ReactScrollView {
14+
15+
private boolean mIsInvertedVirtualizedList = false;
16+
17+
public InvertedScrollView(ReactContext context) {
18+
super(context);
19+
}
20+
21+
22+
// Set whether this ScrollView is used for an inverted virtualized list. When true, we reverse the
23+
// accessibility traversal order to match the visual order.
24+
25+
public void setIsInvertedVirtualizedList(boolean isInverted) {
26+
mIsInvertedVirtualizedList = isInverted;
27+
}
28+
29+
@Override
30+
public void addChildrenForAccessibility(ArrayList<View> outChildren) {
31+
super.addChildrenForAccessibility(outChildren);
32+
if (mIsInvertedVirtualizedList) {
33+
Collections.reverse(outChildren);
34+
}
35+
}
36+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package chat.rocket.reactnative.scroll;
2+
3+
import com.facebook.react.module.annotations.ReactModule;
4+
import com.facebook.react.uimanager.ThemedReactContext;
5+
import com.facebook.react.views.scroll.ReactScrollViewManager;
6+
7+
/**
8+
* View manager for {@link InvertedScrollView}. Registers as "InvertedScrollView" to avoid
9+
* collision with core RCTScrollView. Inherits all ScrollView props from ReactScrollViewManager;
10+
* FlatList passes isInvertedVirtualizedList when inverted, which is applied by the parent setter.
11+
*/
12+
@ReactModule(name = InvertedScrollViewManager.REACT_CLASS)
13+
public class InvertedScrollViewManager extends ReactScrollViewManager {
14+
15+
public static final String REACT_CLASS = "InvertedScrollView";
16+
17+
@Override
18+
public String getName() {
19+
return REACT_CLASS;
20+
}
21+
22+
@Override
23+
public InvertedScrollView createViewInstance(ThemedReactContext context) {
24+
return new InvertedScrollView(context);
25+
}
26+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import React, { forwardRef, useRef, useLayoutEffect } from 'react';
2+
import {
3+
findNodeHandle,
4+
requireNativeComponent,
5+
StyleSheet,
6+
UIManager,
7+
type StyleProp,
8+
type ViewStyle,
9+
type LayoutChangeEvent,
10+
type ScrollViewProps,
11+
type ViewProps
12+
} from 'react-native';
13+
14+
const COMMAND_SCROLL_TO = 1;
15+
const COMMAND_SCROLL_TO_END = 2;
16+
const COMMAND_FLASH_SCROLL_INDICATORS = 3;
17+
18+
const styles = StyleSheet.create({
19+
baseVertical: {
20+
flexGrow: 1,
21+
flexShrink: 1,
22+
flexDirection: 'column',
23+
overflow: 'scroll'
24+
},
25+
baseHorizontal: {
26+
flexGrow: 1,
27+
flexShrink: 1,
28+
flexDirection: 'row',
29+
overflow: 'scroll'
30+
}
31+
});
32+
33+
type ScrollViewPropsWithRef = ScrollViewProps & React.RefAttributes<NativeScrollInstance | null>;
34+
type NativeScrollInstance = React.ComponentRef<NonNullable<typeof NativeInvertedScrollView>>;
35+
interface IScrollableMethods {
36+
scrollTo(options?: { x?: number; y?: number; animated?: boolean }): void;
37+
scrollToEnd(options?: { animated?: boolean }): void;
38+
flashScrollIndicators(): void;
39+
getScrollRef(): NativeScrollInstance | null;
40+
setNativeProps(props: object): void;
41+
}
42+
43+
export type InvertedScrollViewRef = NativeScrollInstance & IScrollableMethods;
44+
45+
const NativeInvertedScrollView = requireNativeComponent<ScrollViewProps>('InvertedScrollView');
46+
47+
const NativeInvertedScrollContentView = requireNativeComponent<ViewProps & { removeClippedSubviews?: boolean }>(
48+
'InvertedScrollContentView'
49+
);
50+
51+
const InvertedScrollView = forwardRef<InvertedScrollViewRef, ScrollViewProps>((props, externalRef) => {
52+
const internalRef = useRef<NativeScrollInstance | null>(null);
53+
54+
useLayoutEffect(() => {
55+
const node = internalRef.current as any;
56+
57+
if (node) {
58+
// 1. Implementation of scrollTo
59+
node.scrollTo = (options?: { x?: number; y?: number; animated?: boolean }) => {
60+
const tag = findNodeHandle(node);
61+
if (tag != null) {
62+
const x = options?.x || 0;
63+
const y = options?.y || 0;
64+
const animated = options?.animated !== false;
65+
UIManager.dispatchViewManagerCommand(tag, COMMAND_SCROLL_TO, [x, y, animated]);
66+
}
67+
};
68+
69+
// 2. Implementation of scrollToEnd
70+
node.scrollToEnd = (options?: { animated?: boolean }) => {
71+
const tag = findNodeHandle(node);
72+
if (tag != null) {
73+
const animated = options?.animated !== false;
74+
UIManager.dispatchViewManagerCommand(tag, COMMAND_SCROLL_TO_END, [animated]);
75+
}
76+
};
77+
78+
// 3. Implementation of flashScrollIndicators
79+
node.flashScrollIndicators = () => {
80+
const tag = findNodeHandle(node as any);
81+
if (tag !== null) {
82+
UIManager.dispatchViewManagerCommand(tag, COMMAND_FLASH_SCROLL_INDICATORS, []);
83+
}
84+
};
85+
86+
node.getScrollRef = () => node;
87+
const originalSetNativeProps = (node as any).setNativeProps;
88+
if (typeof originalSetNativeProps !== 'function') {
89+
node.setNativeProps = (_nativeProps: object) => {};
90+
}
91+
}
92+
}, []);
93+
94+
// Callback Ref to handle merging internal and external refs
95+
const setRef = (node: NativeScrollInstance | null) => {
96+
internalRef.current = node;
97+
98+
if (typeof externalRef === 'function') {
99+
externalRef(node as InvertedScrollViewRef);
100+
} else if (externalRef) {
101+
(externalRef as React.MutableRefObject<NativeScrollInstance | null>).current = node;
102+
}
103+
};
104+
105+
const {
106+
children,
107+
contentContainerStyle,
108+
onContentSizeChange,
109+
removeClippedSubviews,
110+
maintainVisibleContentPosition,
111+
snapToAlignment,
112+
stickyHeaderIndices,
113+
...rest
114+
} = props;
115+
116+
const preserveChildren = maintainVisibleContentPosition != null || snapToAlignment != null;
117+
const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0;
118+
119+
const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' as const } : null, contentContainerStyle];
120+
121+
const contentSizeChangeProps =
122+
onContentSizeChange == null
123+
? undefined
124+
: {
125+
onLayout: (e: LayoutChangeEvent) => {
126+
const { width, height } = e.nativeEvent.layout;
127+
onContentSizeChange(width, height);
128+
}
129+
};
130+
131+
const horizontal = !!props.horizontal;
132+
const baseStyle = horizontal ? styles.baseHorizontal : styles.baseVertical;
133+
const { style, ...restWithoutStyle } = rest;
134+
135+
if (!NativeInvertedScrollView || !NativeInvertedScrollContentView) {
136+
return null;
137+
}
138+
const ScrollView = NativeInvertedScrollView as React.ComponentType<ScrollViewPropsWithRef>;
139+
const ContentView = NativeInvertedScrollContentView as React.ComponentType<ViewProps & { removeClippedSubviews?: boolean }>;
140+
141+
return (
142+
<ScrollView ref={setRef} {...restWithoutStyle} style={StyleSheet.compose(baseStyle, style)} horizontal={horizontal}>
143+
<ContentView
144+
{...contentSizeChangeProps}
145+
removeClippedSubviews={hasStickyHeaders ? false : removeClippedSubviews}
146+
collapsable={false}
147+
collapsableChildren={!preserveChildren}
148+
style={contentContainerStyleArray as StyleProp<ViewStyle>}>
149+
{children}
150+
</ContentView>
151+
</ScrollView>
152+
);
153+
});
154+
155+
InvertedScrollView.displayName = 'InvertedScrollView';
156+
157+
export default InvertedScrollView;

app/views/RoomView/List/components/List.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanim
44

55
import { isIOS } from '../../../../lib/methods/helpers';
66
import scrollPersistTaps from '../../../../lib/methods/helpers/scrollPersistTaps';
7+
import InvertedScrollView from './InvertedScrollView';
78
import NavBottomFAB from './NavBottomFAB';
89
import { type IListProps } from '../definitions';
910
import { SCROLL_LIMIT } from '../constants';
@@ -43,6 +44,7 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => {
4344
contentContainerStyle={styles.contentContainer}
4445
style={styles.list}
4546
inverted
47+
renderScrollComponent={isIOS ? undefined : props => <InvertedScrollView {...props} />}
4648
removeClippedSubviews={isIOS}
4749
initialNumToRender={7}
4850
onEndReachedThreshold={0.5}

0 commit comments

Comments
 (0)