diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java index a4acb0c1e13..9543c1b09ab 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java @@ -11,13 +11,45 @@ */ public class InvertedScrollContentView extends ReactViewGroup { + private boolean mIsInvertedContent = false; + public InvertedScrollContentView(android.content.Context context) { super(context); } + public void setIsInvertedContent(boolean isInverted) { + mIsInvertedContent = isInverted; + } + @Override public void addChildrenForAccessibility(ArrayList outChildren) { super.addChildrenForAccessibility(outChildren); - Collections.reverse(outChildren); + if (mIsInvertedContent) { + Collections.reverse(outChildren); + } + } + + @Override + public void addFocusables(ArrayList views, int direction, int focusableMode) { + super.addFocusables(views, direction, focusableMode); + if (mIsInvertedContent) { + // Find indices of focusables that are children of this view + ArrayList childIndices = new ArrayList<>(); + for (int i = 0; i < views.size(); i++) { + View v = views.get(i); + if (v.getParent() == this) { + childIndices.add(i); + } + } + // Reverse only the sublist of children focusables + int n = childIndices.size(); + for (int i = 0; i < n / 2; i++) { + int idx1 = childIndices.get(i); + int idx2 = childIndices.get(n - 1 - i); + View temp = views.get(idx1); + views.set(idx1, views.get(idx2)); + views.set(idx2, temp); + } + } } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java index d30f9fc84c2..095c2463424 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java @@ -2,6 +2,7 @@ import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.views.view.ReactViewManager; /** @@ -22,4 +23,9 @@ public String getName() { public InvertedScrollContentView createViewInstance(ThemedReactContext context) { return new InvertedScrollContentView(context); } + + @ReactProp(name = "isInvertedContent") + public void setIsInvertedContent(InvertedScrollContentView view, boolean isInverted) { + view.setIsInvertedContent(isInverted); + } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java index def585a7511..673a72cf79d 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java @@ -8,7 +8,8 @@ // When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform which // visually inverts the list but Android still reports children in array order. This view overrides -// addChildrenForAccessibility to reverse the order so TalkBack matches the visual order. +// addChildrenForAccessibility to reverse the order so TalkBack matches the visual order, and also +// adjusts keyboard/D-pad focus navigation to behave like a non-inverted list. public class InvertedScrollView extends ReactScrollView { @@ -21,11 +22,54 @@ public InvertedScrollView(ReactContext context) { // Set whether this ScrollView is used for an inverted virtualized list. When true, we reverse the // accessibility traversal order to match the visual order. - + public void setIsInvertedVirtualizedList(boolean isInverted) { mIsInvertedVirtualizedList = isInverted; } + @Override + public View focusSearch(View focused, int direction) { + if (mIsInvertedVirtualizedList) { + switch (direction) { + case View.FOCUS_DOWN: + direction = View.FOCUS_UP; + break; + case View.FOCUS_UP: + direction = View.FOCUS_DOWN; + break; + case View.FOCUS_FORWARD: + direction = View.FOCUS_BACKWARD; + break; + case View.FOCUS_BACKWARD: + direction = View.FOCUS_FORWARD; + break; + default: + break; + } + } + return super.focusSearch(focused, direction); + } + + @Override + public void addFocusables(ArrayList views, int direction, int focusableMode) { + int initialSize = views.size(); + + super.addFocusables(views, direction, focusableMode); + + if (!mIsInvertedVirtualizedList) { + return; + } + + if (direction == View.FOCUS_FORWARD || direction == View.FOCUS_BACKWARD) { + int newSize = views.size(); + int addedCount = newSize - initialSize; + if (addedCount > 1) { + java.util.List subList = views.subList(initialSize, newSize); + Collections.reverse(subList); + } + } + } + @Override public void addChildrenForAccessibility(ArrayList outChildren) { super.addChildrenForAccessibility(outChildren); diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index c160fbcc42a..89bc255ebc3 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -31,6 +31,7 @@ const styles = StyleSheet.create({ }); type ScrollViewPropsWithRef = ScrollViewProps & React.RefAttributes; +type InvertedScrollViewProps = ScrollViewProps & { inverted?: boolean }; type NativeScrollInstance = React.ComponentRef>; interface IScrollableMethods { scrollTo(options?: { x?: number; y?: number; animated?: boolean }): void; @@ -44,11 +45,11 @@ export type InvertedScrollViewRef = NativeScrollInstance & IScrollableMethods; const NativeInvertedScrollView = requireNativeComponent('InvertedScrollView'); -const NativeInvertedScrollContentView = requireNativeComponent( - 'InvertedScrollContentView' -); +const NativeInvertedScrollContentView = requireNativeComponent< + ViewProps & { removeClippedSubviews?: boolean; isInvertedContent?: boolean } +>('InvertedScrollContentView'); -const InvertedScrollView = forwardRef((props, externalRef) => { +const InvertedScrollView = forwardRef((props, externalRef) => { const internalRef = useRef(null); useLayoutEffect(() => { @@ -136,13 +137,16 @@ const InvertedScrollView = forwardRef((p return null; } const ScrollView = NativeInvertedScrollView as React.ComponentType; - const ContentView = NativeInvertedScrollContentView as React.ComponentType; - + const ContentView = NativeInvertedScrollContentView as React.ComponentType< + ViewProps & { removeClippedSubviews?: boolean; isInvertedContent?: boolean } + >; + console.log('props.inverted', props.inverted); return ( }> diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 7ffe0135587..64ee74a8e79 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'; import { isIOS } from '../../../../lib/methods/helpers'; @@ -22,6 +22,41 @@ const styles = StyleSheet.create({ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { const [visible, setVisible] = useState(false); const { isAutocompleteVisible } = useRoomContext(); + const { data, renderItem, ...flatListProps } = props; + + const renderItemWithFocus: IListProps['renderItem'] = info => { + if (!renderItem) { + return null as any; + } + + if (Platform.OS !== 'android') { + return renderItem(info); + } + + const total = data?.length ?? 0; + const { index } = info; + const itemId = `room-message-${index}`; + + const nextFocusUp = index < total - 1 ? `room-message-${index + 1}` : undefined; + const nextFocusDown = index > 0 ? `room-message-${index - 1}` : undefined; + + return ( + + {renderItem(info)} + + ); + }; + const scrollHandler = useAnimatedScrollHandler({ onScroll: event => { if (event.contentOffset.y > SCROLL_LIMIT) { @@ -36,15 +71,18 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { {/* @ts-ignore */} item.id} + data={data} + renderItem={renderItemWithFocus} contentContainerStyle={styles.contentContainer} style={styles.list} - inverted - renderScrollComponent={isIOS ? undefined : props => } + inverted={props.inverted || true} + renderScrollComponent={isIOS ? undefined : scrollProps => } removeClippedSubviews={isIOS} initialNumToRender={7} onEndReachedThreshold={0.5} @@ -52,7 +90,6 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { windowSize={10} scrollEventThrottle={16} onScroll={scrollHandler} - {...props} {...scrollPersistTaps} />