diff --git a/.argent/flows/__baselines__/button-child-order-test/opacity-row-off__ios-1206x2622-crop-35b450c0.png b/.argent/flows/__baselines__/button-child-order-test/opacity-row-off__ios-1206x2622-crop-35b450c0.png new file mode 100644 index 0000000000..6ca7b01ef0 Binary files /dev/null and b/.argent/flows/__baselines__/button-child-order-test/opacity-row-off__ios-1206x2622-crop-35b450c0.png differ diff --git a/.argent/flows/__baselines__/button-child-order-test/opacity-row-on__ios-1206x2622-crop-35b450c0.png b/.argent/flows/__baselines__/button-child-order-test/opacity-row-on__ios-1206x2622-crop-35b450c0.png new file mode 100644 index 0000000000..9676ec6147 Binary files /dev/null and b/.argent/flows/__baselines__/button-child-order-test/opacity-row-on__ios-1206x2622-crop-35b450c0.png differ diff --git a/.argent/flows/button-child-order-test.yaml b/.argent/flows/button-child-order-test.yaml new file mode 100644 index 0000000000..8893a9e72d --- /dev/null +++ b/.argent/flows/button-child-order-test.yaml @@ -0,0 +1,32 @@ +# The snapshots run on iOS only: the bug was iOS-specific (the button's +# underlay layer shifted Fabric's mount index), so baselines exist for iOS only. +steps: + - echo: Launching Expo Example + - launch: com.example.ExpoExample + - await: { visible: { text: "Empty Example" }, timeout: 15000 } + - echo: "On the Home list, search for the button child order example" + - type: { text: "button child order", into: { id: "search-examples" } } + - await: { visible: { text: "Button child order" } } + - echo: "Tap the example to open it" + - tap: { text: "Button child order" } + - await: { visible: { id: "button-child-order-toggle" } } + - await: { idle: true } + + - echo: "Toggle on - the opacity change remounts each label inside its button" + - tap: { id: "button-child-order-toggle" } + - await: { visible: { text: "Toggle (on)" } } + - await: { idle: true } + - when: { platform: ios } + steps: + - echo: "Every button column must still show its label, like the View column" + - snapshot: + { name: opacity-row-on, cropOn: { id: "button-child-order-opacity-row" } } + + - echo: "Toggle off - the labels are remounted again" + - tap: { id: "button-child-order-toggle" } + - await: { visible: { text: "Toggle (off)" } } + - await: { idle: true } + - when: { platform: ios } + steps: + - snapshot: + { name: opacity-row-off, cropOn: { id: "button-child-order-opacity-row" } } diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 953e2241c6..1b62332eaf 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -34,6 +34,7 @@ import PanExample from './simple/pan'; import PinchExample from './simple/pinch'; import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; +import ButtonChildOrderExample from './tests/buttonChildOrder'; import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps'; import NativeInterceptCancellationExample from './tests/nativeInterceptCancellation'; import NestedPressablesExample from './tests/nestedPressables'; @@ -131,6 +132,7 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'Modal with Nested Root View', component: NestedRootViewExample }, { name: 'Nested pressables', component: NestedPressablesExample }, { name: 'Nested touchables', component: NestedTouchablesExample }, + { name: 'Button child order', component: ButtonChildOrderExample }, { name: 'Native intercept cancellation', component: NativeInterceptCancellationExample, diff --git a/apps/common-app/src/new_api/tests/buttonChildOrder/index.tsx b/apps/common-app/src/new_api/tests/buttonChildOrder/index.tsx new file mode 100644 index 0000000000..910acd902d --- /dev/null +++ b/apps/common-app/src/new_api/tests/buttonChildOrder/index.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { + BaseButton, + RectButton, + Touchable, +} from 'react-native-gesture-handler'; + +import { COLORS } from '../../../common'; + +// Children mounted into a button after its first render must keep their React +// order. The background-only `frame` View is flattened, so its children are +// hoisted into the button right after it; if they are inserted below it they +// disappear. Every column should look like the `View` control after "Toggle". + +type CellProps = { children: React.ReactNode }; + +const COLUMNS: { name: string; Wrapper: React.FC }[] = [ + { + name: 'BaseButton', + Wrapper: ({ children }) => {children}, + }, + { + name: 'RectButton', + Wrapper: ({ children }) => {children}, + }, + { + name: 'Touchable', + Wrapper: ({ children }) => {children}, + }, + { + name: 'View', + Wrapper: ({ children }) => {children}, + }, +]; + +export default function ButtonChildOrderExample() { + const [toggled, setToggled] = useState(false); + + return ( + + setToggled((value) => !value)}> + Toggle ({toggled ? 'on' : 'off'}) + + + Swap label and ActivityIndicator + + {COLUMNS.map(({ name, Wrapper }) => ( + + + + {toggled ? ( + + ) : ( + Label + )} + + + {name} + + ))} + + + Toggle a child's opacity (1 and 0.5) + + {COLUMNS.map(({ name, Wrapper }) => ( + + + + + Label + + + + {name} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, paddingTop: 16 }, + toggle: { + marginHorizontal: 16, + padding: 12, + alignItems: 'center', + borderRadius: 8, + backgroundColor: COLORS.GRAY, + }, + title: { marginHorizontal: 16, marginTop: 24, fontWeight: '600' }, + row: { flexDirection: 'row', paddingHorizontal: 12, marginTop: 8 }, + cell: { flex: 1, paddingHorizontal: 4 }, + frame: { + height: 48, + borderRadius: 8, + backgroundColor: COLORS.DARK_PURPLE, + alignItems: 'center', + justifyContent: 'center', + }, + label: { color: 'white', fontWeight: '600' }, + caption: { fontSize: 11, marginTop: 4, textAlign: 'center' }, +}); diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index fb0ff7ecb7..37b902ad9a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -151,7 +151,25 @@ - (void)prepareForRecycle - (void)mountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index { +#if !TARGET_OS_OSX + // Same check as the default `UIView` implementation this replaces. + react_native_assert(childComponentView.superview == nil && "Attempt to mount already mounted component view"); + + // `_buttonView.layer` keeps the underlay layer at sublayer index 0, and + // `-[UIView insertSubview:atIndex:]` counts every sublayer, not only those + // backing subviews. Inserting at Fabric's index would therefore place the + // child one position too low, i.e. *below* the sibling it should cover (e.g. + // a label hoisted out of a flattened background View ends up behind it). + // Insert relative to the sibling subview instead, which ignores the underlay. + NSArray *subviews = _buttonView.subviews; + if (index < (NSInteger)subviews.count) { + [_buttonView insertSubview:childComponentView belowSubview:subviews[index]]; + } else { + [_buttonView addSubview:childComponentView]; + } +#else [_buttonView mountChildComponentView:childComponentView index:index]; +#endif } - (void)unmountChildComponentView:(RNGHUIView *)childComponentView