From d38bf60050cd52ee739c62a290f979fcc0785234 Mon Sep 17 00:00:00 2001 From: Lucas Reis Date: Thu, 26 Jun 2025 06:25:48 -0300 Subject: [PATCH] Disable Animations when Accessibility configuration is to remove it (#8743) * feat: add support for reduced motion in BottomSheet and TabBar components * fix: remove reduceMotion option from animation timing in login, onboarding, and server screens * feat: integrate reduced motion support to the entire app and switch accordingly * feat: positions the Login screen differently if animations are disabled * fix: remove mock implementation of useReducedMotion in react-native-reanimated * revert login screen * fix: remove unused effect that resets translateX value in LoginOptions * feat: add reduced motion support to ForgotPassword screen and reset translateX on LoginOptions mount * feat: integrate reduced motion support in Onboarding and Slide components * feat: add reduced motion support to MFA and SSO screens * feat: update ReducedMotionConfig to use system preference in withServerDatabase * refactor: remove ReducedMotionConfig from withServerDatabase component * feat: remove reduced motion configuration from screens and adjust animations accordingly * feat: integrate reduced motion handling in Server component animations * feat: enhance BottomSheet animation with reduced motion support and update test setup for react-native-reanimated * fix: update channel list row snapshots with collapsable and animated props * test: update react-native-reanimated mock setup for improved testing * fix: enhance react-native-reanimated mock to support reduced motion and prevent default call * fix: refactor animationConfigs to use useMemo for improved performance and clarity * feat: implement screen transition animation hook and integrate it into ForgotPassword screen * fix: refactor LoginOptions to utilize useScreenTransitionAnimation for improved animation handling * refactor: streamline MFA component by removing unused imports and integrating useScreenTransitionAnimation for enhanced transitions * refactor: simplify Onboarding component by removing unused imports and integrating useScreenTransitionAnimation for smoother transitions * refactor: enhance useScreenTransitionAnimation hook to support animated transitions and integrate it into Server component * refactor: replace custom animation logic with useScreenTransitionAnimation in SSO component for improved transition handling --- app/hooks/screen_transition_animation.ts | 44 ++++++++++++++++++++ app/screens/bottom_sheet/index.tsx | 11 +++-- app/screens/forgot_password/index.tsx | 38 ++++------------- app/screens/login/index.tsx | 31 ++------------ app/screens/mfa/index.tsx | 37 +++------------- app/screens/onboarding/index.tsx | 35 ++-------------- app/screens/onboarding/slide.tsx | 11 ++--- app/screens/server/index.tsx | 28 ++++--------- app/screens/sso/index.tsx | 38 +++-------------- patches/react-native-reanimated+3.17.3.patch | 12 ------ test/setup.ts | 7 +++- 11 files changed, 98 insertions(+), 194 deletions(-) create mode 100644 app/hooks/screen_transition_animation.ts delete mode 100644 patches/react-native-reanimated+3.17.3.patch diff --git a/app/hooks/screen_transition_animation.ts b/app/hooks/screen_transition_animation.ts new file mode 100644 index 000000000..a99ed6c0f --- /dev/null +++ b/app/hooks/screen_transition_animation.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect} from 'react'; +import {Platform, useWindowDimensions} from 'react-native'; +import {Navigation} from 'react-native-navigation'; +import {useReducedMotion, useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated'; + +export const useScreenTransitionAnimation = (componentId: string, animated: boolean = true) => { + const {width} = useWindowDimensions(); + const reducedMotion = useReducedMotion(); + const shouldAnimate = animated && !reducedMotion; + const translateX = useSharedValue(shouldAnimate ? width : 0); + + const animatedStyle = useAnimatedStyle(() => { + const duration = Platform.OS === 'android' ? 250 : 350; + return { + transform: [{translateX: withTiming(translateX.value, {duration})}], + }; + }, []); + + useEffect(() => { + const listener = { + componentDidAppear: () => { + translateX.value = 0; + }, + componentDidDisappear: () => { + translateX.value = shouldAnimate ? -width : 0; + }, + }; + + const unsubscribe = Navigation.events().registerComponentListener(listener, componentId); + + return () => unsubscribe.remove(); + }, [componentId, translateX, width, reducedMotion, shouldAnimate]); + + useEffect(() => { + if (!shouldAnimate) { + translateX.value = 0; + } + }, [translateX, shouldAnimate]); + + return animatedStyle; +}; diff --git a/app/screens/bottom_sheet/index.tsx b/app/screens/bottom_sheet/index.tsx index 76540a048..b6ea42eda 100644 --- a/app/screens/bottom_sheet/index.tsx +++ b/app/screens/bottom_sheet/index.tsx @@ -4,7 +4,7 @@ import BottomSheetM, {BottomSheetBackdrop, BottomSheetView, type BottomSheetBackdropProps} from '@gorhom/bottom-sheet'; import React, {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'; import {DeviceEventEmitter, type Handle, InteractionManager, Keyboard, type StyleProp, View, type ViewStyle} from 'react-native'; -import {ReduceMotion, type WithSpringConfig} from 'react-native-reanimated'; +import {ReduceMotion, useReducedMotion, type WithSpringConfig} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {Events} from '@constants'; @@ -86,7 +86,6 @@ export const animatedConfig: Omit = { overshootClamping: true, restSpeedThreshold: 0.3, restDisplacementThreshold: 0.3, - reduceMotion: ReduceMotion.Never, }; const BottomSheet = ({ @@ -100,6 +99,7 @@ const BottomSheet = ({ testID, enableDynamicSizing = false, }: Props) => { + const reducedMotion = useReducedMotion(); const sheetRef = useRef(null); const isTablet = useIsTablet(); const insets = useSafeAreaInsets(); @@ -108,6 +108,11 @@ const BottomSheet = ({ const interaction = useRef(); const timeoutRef = useRef(); + const animationConfigs = useMemo(() => ({ + ...animatedConfig, + reduceMotion: reducedMotion ? ReduceMotion.Always : ReduceMotion.Never, + }), [reducedMotion]); + useEffect(() => { interaction.current = InteractionManager.createInteractionHandle(); }, []); @@ -221,7 +226,7 @@ const BottomSheet = ({ backdropComponent={renderBackdrop} onAnimate={handleAnimationStart} onChange={handleChange} - animationConfigs={animatedConfig} + animationConfigs={animationConfigs} handleComponent={Indicator} style={styles.bottomSheet} backgroundStyle={bottomSheetBackgroundStyle} diff --git a/app/screens/forgot_password/index.tsx b/app/screens/forgot_password/index.tsx index a1c155641..a902fa776 100644 --- a/app/screens/forgot_password/index.tsx +++ b/app/screens/forgot_password/index.tsx @@ -1,12 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; -import {Keyboard, Platform, Text, useWindowDimensions, View} from 'react-native'; +import {Keyboard, Platform, Text, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {Navigation} from 'react-native-navigation'; -import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import {SafeAreaView} from 'react-native-safe-area-context'; import {sendPasswordResetEmail} from '@actions/remote/session'; @@ -16,6 +16,7 @@ import FormattedText from '@components/formatted_text'; import {Screens} from '@constants'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useAvoidKeyboard} from '@hooks/device'; +import {useScreenTransitionAnimation} from '@hooks/screen_transition_animation'; import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; import {isEmail} from '@utils/helpers'; @@ -91,8 +92,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ })); const ForgotPassword = ({componentId, serverUrl, theme}: Props) => { - const dimensions = useWindowDimensions(); - const translateX = useSharedValue(dimensions.width); const [email, setEmail] = useState(''); const [error, setError] = useState(''); const [isPasswordLinkSent, setIsPasswordLinkSent] = useState(false); @@ -100,6 +99,8 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => { const keyboardAwareRef = useRef(null); const styles = getStyleSheet(theme); + const animatedStyles = useScreenTransitionAnimation(componentId); + useAvoidKeyboard(keyboardAwareRef); const changeEmail = useCallback((emailAddress: string) => { @@ -234,31 +235,6 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => { ); }; - const transform = useAnimatedStyle(() => { - const duration = Platform.OS === 'android' ? 250 : 350; - return { - transform: [{translateX: withTiming(translateX.value, {duration})}], - }; - }, []); - - useEffect(() => { - const listener = { - componentDidAppear: () => { - translateX.value = 0; - }, - componentDidDisappear: () => { - translateX.value = -dimensions.width; - }, - }; - const unsubscribe = Navigation.events().registerComponentListener(listener, componentId); - - return () => unsubscribe.remove(); - }, [componentId, dimensions]); - - useEffect(() => { - translateX.value = 0; - }, []); - useAndroidHardwareBackHandler(componentId, onReturn); return ( @@ -266,7 +242,7 @@ const ForgotPassword = ({componentId, serverUrl, theme}: Props) => { {getCenterContent()} diff --git a/app/screens/login/index.tsx b/app/screens/login/index.tsx index 5bde5e49c..f260233b2 100644 --- a/app/screens/login/index.tsx +++ b/app/screens/login/index.tsx @@ -5,7 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Platform, useWindowDimensions, View, type LayoutChangeEvent} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {Navigation} from 'react-native-navigation'; -import Animated, {ReduceMotion, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import {SafeAreaView} from 'react-native-safe-area-context'; import FormattedText from '@components/formatted_text'; @@ -14,6 +14,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import {useIsTablet} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {useScreenTransitionAnimation} from '@hooks/screen_transition_animation'; import NetworkManager from '@managers/network_manager'; import SecurityManager from '@managers/security_manager'; import Background from '@screens/background'; @@ -86,7 +87,6 @@ const LoginOptions = ({ const dimensions = useWindowDimensions(); const defaultHeaderHeight = useDefaultHeaderHeight(); const isTablet = useIsTablet(); - const translateX = useSharedValue(dimensions.width); const [contentFillScreen, setContentFillScreen] = useState(false); const numberSSOs = useMemo(() => { return Object.values(ssoOptions).filter((v) => v.enabled).length; @@ -132,13 +132,6 @@ const LoginOptions = ({ /> ); - const transform = useAnimatedStyle(() => { - const duration = Platform.OS === 'android' ? 250 : 350; - return { - transform: [{translateX: withTiming(translateX.value, {duration, reduceMotion: ReduceMotion.Never})}], - }; - }, []); - const dismiss = () => { dismissModal({componentId}); }; @@ -163,23 +156,7 @@ const LoginOptions = ({ return () => navigationEvents.remove(); }, [closeButtonId, componentId, serverUrl]); - useEffect(() => { - translateX.value = 0; - }, []); - - useEffect(() => { - const listener = { - componentDidAppear: () => { - translateX.value = 0; - }, - componentDidDisappear: () => { - translateX.value = -dimensions.width; - }, - }; - const unsubscribe = Navigation.events().registerComponentListener(listener, Screens.LOGIN); - - return () => unsubscribe.remove(); - }, [dimensions, translateX]); + const animatedStyles = useScreenTransitionAnimation(Screens.LOGIN); useNavButtonPressed(closeButtonId || '', componentId, dismiss, []); useAndroidHardwareBackHandler(componentId, pop); @@ -217,7 +194,7 @@ const LoginOptions = ({ nativeID={SecurityManager.getShieldScreenId(componentId, false, true)} > - + ({ const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView); const MFA = ({componentId, config, goToHome, license, loginId, password, serverDisplayName, serverUrl, theme}: MFAProps) => { - const dimensions = useWindowDimensions(); - const translateX = useSharedValue(dimensions.width); const keyboardAwareRef = useRef(null); const intl = useIntl(); const [token, setToken] = useState(''); @@ -120,33 +118,10 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD goToHome(result.error); }, [config, formatMessage, goToHome, intl, license, loginId, password, serverDisplayName, serverUrl, token])); - const transform = useAnimatedStyle(() => { - const duration = Platform.OS === 'android' ? 250 : 350; - return { - transform: [{translateX: withTiming(translateX.value, {duration})}], - }; - }, []); + const animatedStyles = useScreenTransitionAnimation(componentId); useAvoidKeyboard(keyboardAwareRef, 2); - useEffect(() => { - const listener = { - componentDidAppear: () => { - translateX.value = 0; - }, - componentDidDisappear: () => { - translateX.value = -dimensions.width; - }, - }; - const unsubscribe = Navigation.events().registerComponentListener(listener, componentId); - - return () => unsubscribe.remove(); - }, [componentId, dimensions, translateX]); - - useEffect(() => { - translateX.value = 0; - }, []); - const close = useCallback(() => { popTopScreen(componentId); }, [componentId]); @@ -161,7 +136,7 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD Math.round(scrollX.value / width)); const moveToSlide = useCallback((slideIndexToMove: number) => { @@ -95,26 +91,7 @@ const Onboarding = ({ scrollX.value = event.nativeEvent.contentOffset.x; }, []); - useEffect(() => { - const listener = { - componentDidAppear: () => { - translateX.value = 0; - }, - componentDidDisappear: () => { - translateX.value = -width; - }, - }; - const unsubscribe = Navigation.events().registerComponentListener(listener, Screens.ONBOARDING); - - return () => unsubscribe.remove(); - }, [width]); - - const transform = useAnimatedStyle(() => { - const duration = Platform.OS === 'android' ? 250 : 350; - return { - transform: [{translateX: withTiming(translateX.value, {duration, reduceMotion: ReduceMotion.Never})}], - }; - }, []); + const animatedStyles = useScreenTransitionAnimation(Screens.ONBOARDING); useEffect(() => { const listener = BackHandler.addEventListener('hardwareBackPress', () => { @@ -129,10 +106,6 @@ const Onboarding = ({ return () => listener.remove(); }, []); - useEffect(() => { - translateX.value = 0; - }, []); - return ( { const {width} = useWindowDimensions(); const styles = getStyleSheet(theme); + const reducedMotion = useReducedMotion(); /** * Code used to animate the first image load */ const [firstLoad, setFirstLoad] = useState(true); - const initialImagePosition = useSharedValue(width); - const initialTitlePosition = useSharedValue(width); - const initialDescriptionPosition = useSharedValue(width); + const initialImagePosition = useSharedValue(reducedMotion ? 0 : width); + const initialTitlePosition = useSharedValue(reducedMotion ? 0 : width); + const initialDescriptionPosition = useSharedValue(reducedMotion ? 0 : width); - const initialElementsOpacity = useSharedValue(0); + const initialElementsOpacity = useSharedValue(reducedMotion ? 1 : 0); useEffect(() => { if (index === FIRST_SLIDE) { diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 25d2fbebb..9baf87c82 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -4,10 +4,10 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, BackHandler, Platform, useWindowDimensions, View} from 'react-native'; +import {Alert, BackHandler, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {Navigation} from 'react-native-navigation'; -import Animated, {ReduceMotion, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import {SafeAreaView} from 'react-native-safe-area-context'; import {doPing} from '@actions/remote/general'; @@ -16,6 +16,7 @@ import LocalConfig from '@assets/config.json'; import AppVersion from '@components/app_version'; import {Screens, Launch, DeepLink} from '@constants'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {useScreenTransitionAnimation} from '@hooks/screen_transition_animation'; import {t} from '@i18n'; import {getServerCredentials} from '@init/credentials'; import PushNotifications from '@init/push_notifications'; @@ -81,8 +82,6 @@ const Server = ({ }: ServerProps) => { const intl = useIntl(); const managedConfig = useManagedConfig(); - const dimensions = useWindowDimensions(); - const translateX = useSharedValue(animated ? dimensions.width : 0); const keyboardAwareRef = useRef(null); const [connecting, setConnecting] = useState(false); const [displayName, setDisplayName] = useState(''); @@ -100,6 +99,8 @@ const Server = ({ dismissModal({componentId}); }; + const animatedStyles = useScreenTransitionAnimation(componentId, animated); + useEffect(() => { let serverName: string | undefined = defaultDisplayName || managedConfig?.serverName || LocalConfig.DefaultServerName; let serverUrl: string | undefined = defaultServerUrl || managedConfig?.serverUrl || LocalConfig.DefaultServerUrl; @@ -150,19 +151,15 @@ const Server = ({ useEffect(() => { const listener = { componentDidAppear: () => { - translateX.value = 0; if (url) { NetworkManager.invalidateClient(url); } }, - componentDidDisappear: () => { - translateX.value = -dimensions.width; - }, }; const unsubscribe = Navigation.events().registerComponentListener(listener, componentId); return () => unsubscribe.remove(); - }, [componentId, url, dimensions]); + }, [componentId, url]); useEffect(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { @@ -183,10 +180,6 @@ const Server = ({ return () => backHandler.remove(); }, []); - useEffect(() => { - translateX.value = 0; - }, []); - useNavButtonPressed(closeButtonId || '', componentId, dismiss, []); const displayLogin = (serverUrl: string, config: ClientConfig, license: ClientLicense) => { @@ -373,13 +366,6 @@ const Server = ({ displayLogin(ping.url, data.config!, data.license!); }; - const transform = useAnimatedStyle(() => { - const duration = Platform.OS === 'android' ? 250 : 350; - return { - transform: [{translateX: withTiming(translateX.value, {duration, reduceMotion: ReduceMotion.Never})}], - }; - }, []); - return ( { - const dimensions = useWindowDimensions(); - const translateX = useSharedValue(dimensions.width); - const [loginError, setLoginError] = useState(''); let loginUrl = ''; switch (ssoType) { @@ -112,30 +109,7 @@ const SSO = ({ dismissModal({componentId}); }, [componentId, serverUrl]); - const transform = useAnimatedStyle(() => { - const duration = Platform.OS === 'android' ? 250 : 350; - return { - transform: [{translateX: withTiming(translateX.value, {duration})}], - }; - }, []); - - useEffect(() => { - const listener = { - componentDidAppear: () => { - translateX.value = 0; - }, - componentDidDisappear: () => { - translateX.value = -dimensions.width; - }, - }; - const unsubscribe = Navigation.events().registerComponentListener(listener, Screens.SSO); - - return () => unsubscribe.remove(); - }, [dimensions]); - - useEffect(() => { - translateX.value = 0; - }, []); + const animatedStyles = useScreenTransitionAnimation(Screens.SSO); useNavButtonPressed(closeButtonId || '', componentId, dismiss, []); @@ -178,7 +152,7 @@ const SSO = ({ style={styles.flex} > - + {authentication} diff --git a/patches/react-native-reanimated+3.17.3.patch b/patches/react-native-reanimated+3.17.3.patch deleted file mode 100644 index 873b0e10a..000000000 --- a/patches/react-native-reanimated+3.17.3.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/node_modules/react-native-reanimated/src/mock.ts b/node_modules/react-native-reanimated/src/mock.ts -index 3d8e3f8..b32d457 100644 ---- a/node_modules/react-native-reanimated/src/mock.ts -+++ b/node_modules/react-native-reanimated/src/mock.ts -@@ -114,6 +114,7 @@ const hook = { - }), - // useFrameCallback: ADD ME IF NEEDED - useAnimatedKeyboard: () => ({ height: 0, state: 0 }), -+ useReducedMotion: () => false, - // useScrollViewOffset: ADD ME IF NEEDED - }; - diff --git a/test/setup.ts b/test/setup.ts index 1c05ba302..010ff0038 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -457,7 +457,12 @@ jest.mock('@mattermost/react-native-network-client', () => ({ jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); -jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock')); +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + Reanimated.default.call = () => undefined; + Reanimated.useReducedMotion = jest.fn(() => false); + return Reanimated; +}); jest.mock('react-native-permissions', () => require('react-native-permissions/mock')); jest.mock('react-native-haptic-feedback', () => {