diff --git a/demo/src/screens/componentScreens/ScreenFooterScreen.tsx b/demo/src/screens/componentScreens/ScreenFooterScreen.tsx index a0e451b935..b703415de4 100644 --- a/demo/src/screens/componentScreens/ScreenFooterScreen.tsx +++ b/demo/src/screens/componentScreens/ScreenFooterScreen.tsx @@ -10,6 +10,7 @@ import { ScreenFooterLayouts, ScreenFooterBackgrounds, KeyboardBehavior, + ScreenFooterAnimationTypeProp, FooterAlignment, HorizontalItemsDistribution, ItemsFit, @@ -88,6 +89,12 @@ const KEYBOARD_BEHAVIOR_OPTIONS = [ {label: 'Hoisted', value: KeyboardBehavior.HOISTED} ]; +const ANIMATION_TYPE_OPTIONS = [ + {label: 'Slide', value: 'slide'}, + {label: 'Fade', value: 'fade'}, + {label: 'None', value: 'none'} +]; + const KEYBOARD_BEHAVIOR_OPTIONS_SPACED = [ {label: 'Sticky', value: KeyboardBehavior.STICKY}, {label: 'Hoisted', value: KeyboardBehavior.HOISTED}, @@ -115,6 +122,7 @@ const ScreenFooterContent = () => { const [layout, setLayout] = useState(ScreenFooterLayouts.HORIZONTAL); const [background, setBackground] = useState(ScreenFooterBackgrounds.SOLID); const [keyboardBehavior, setKeyboardBehavior] = useState(KeyboardBehavior.STICKY); + const [animationType, setAnimationType] = useState('slide'); const [alignment, setAlignment] = useState(FooterAlignment.CENTER); const [horizontalAlignment, setHorizontalAlignment] = useState(FooterAlignment.CENTER); const [distribution, setDistribution] = useState(HorizontalItemsDistribution.STACK); @@ -390,6 +398,21 @@ const ScreenFooterContent = () => { + {/* Animation type */} + + + Animation Type + + + opt.value === animationType)} + onChangeIndex={index => setAnimationType(ANIMATION_TYPE_OPTIONS[index].value)} + /> + + + + {/* Alignment (Cross Axis) */} @@ -478,6 +501,7 @@ const ScreenFooterContent = () => { layout={layout} backgroundType={background} keyboardBehavior={keyboardBehavior} + animationType={animationType} alignment={alignment} horizontalAlignment={horizontalAlignment} horizontalItemsDistribution={distribution} diff --git a/packages/react-native-ui-lib/src/components/floatingButton/index.tsx b/packages/react-native-ui-lib/src/components/floatingButton/index.tsx index 7951bda202..189092c6da 100644 --- a/packages/react-native-ui-lib/src/components/floatingButton/index.tsx +++ b/packages/react-native-ui-lib/src/components/floatingButton/index.tsx @@ -11,7 +11,7 @@ export enum FloatingButtonLayouts { HORIZONTAL = 'Horizontal' } -export interface FloatingButtonProps extends Pick { +export interface FloatingButtonProps extends Pick { /** * Whether the button is visible */ @@ -82,6 +82,7 @@ const FloatingButton = (props: FloatingButtonProps) => { hideBackgroundOverlay, hoisted = Constants.isAndroid, isAndroidEdgeToEdge, + animationType, testID } = props; @@ -162,6 +163,7 @@ const FloatingButton = (props: FloatingButtonProps) => { keyboardBehavior={hoisted ? KeyboardBehavior.HOISTED : KeyboardBehavior.STICKY} isAndroidEdgeToEdge={isAndroidEdgeToEdge} animationDuration={withoutAnimation ? 0 : duration} + animationType={animationType} itemsFit={fullWidth ? ItemsFit.STRETCH : undefined} contentContainerStyle={footerContentContainerStyle} testID={testID} diff --git a/packages/react-native-ui-lib/src/components/screenFooter/index.tsx b/packages/react-native-ui-lib/src/components/screenFooter/index.tsx index 1ed4c09d9b..cd4ccf5178 100644 --- a/packages/react-native-ui-lib/src/components/screenFooter/index.tsx +++ b/packages/react-native-ui-lib/src/components/screenFooter/index.tsx @@ -1,6 +1,6 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {LayoutChangeEvent, StyleSheet, ViewStyle} from 'react-native'; -import Animated, {useAnimatedKeyboard, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import {Keyboard} from 'uilib-native'; import {SafeAreaContextPackage} from '../../optionalDependencies'; import View from '../view'; @@ -9,6 +9,7 @@ import Assets from '../../assets'; import {Colors, Shadows, Spacings} from '../../style'; import {asBaseComponent, Constants} from '../../commons/new'; import {useKeyboardHeight} from '../../hooks'; +import useAnimatedFooterStyle from './useAnimatedFooterStyle'; import { ScreenFooterProps, ScreenFooterLayouts, @@ -17,6 +18,7 @@ import { HorizontalItemsDistribution, ItemsFit, KeyboardBehavior, + ScreenFooterAnimationTypeProp, ScreenFooterShadow } from './types'; @@ -28,9 +30,9 @@ export { HorizontalItemsDistribution, ItemsFit, KeyboardBehavior, + ScreenFooterAnimationTypeProp, ScreenFooterShadow }; -const androidVersion = Constants.getAndroidVersion(); const ScreenFooter = (props: ScreenFooterProps) => { const { testID, @@ -44,41 +46,22 @@ const ScreenFooter = (props: ScreenFooterProps) => { itemWidth, horizontalItemsDistribution: distribution, visible = true, - animationDuration = 200, + animationDuration, + animationType, shadow = ScreenFooterShadow.SH20, hideDivider = false, - isAndroidEdgeToEdge = !!androidVersion && androidVersion >= 35 ? true : undefined, + isAndroidEdgeToEdge, containerStyle: containerStyleOverride, contentContainerStyle: contentContainerStyleOverride } = props; - const withoutAnimation = animationDuration === 0; - - const keyboard = useAnimatedKeyboard({ - isNavigationBarTranslucentAndroid: isAndroidEdgeToEdge, - isStatusBarTranslucentAndroid: isAndroidEdgeToEdge - }); - const [height, setHeight] = useState(0); - const visibilityTranslateY = useSharedValue(0); - - // Update visibility translation when visible or height changes - useEffect(() => { - visibilityTranslateY.value = withTiming(visible ? 0 : height, {duration: animationDuration}); - }, [visible, height, animationDuration, visibilityTranslateY]); - - // Animated style for STICKY behavior (counters Android system offset + visibility) - const stickyAnimatedStyle = useAnimatedStyle(() => { - const counterSystemOffset = Constants.isAndroid ? keyboard.height.value : 0; - return { - transform: [{translateY: counterSystemOffset + visibilityTranslateY.value}] - }; - }); - - // Animated style for HOISTED behavior (visibility only, keyboard handled by KeyboardAccessoryView) - const hoistedAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [{translateY: visibilityTranslateY.value}] - }; + const {containerStyle, setHeight} = useAnimatedFooterStyle({ + animationDuration, + animationType, + keyboardBehavior, + visible, + isAndroidEdgeToEdge, + containerStyle: containerStyleOverride }); const onLayout = useCallback((event: LayoutChangeEvent) => { @@ -202,30 +185,28 @@ const ScreenFooter = (props: ScreenFooterProps) => { return null; }, [testID, isSolid, isFading, solidBackgroundStyle]); - const renderChild = useCallback( - (child: React.ReactNode, index: number) => { - if (itemsFit === ItemsFit.FIXED && itemWidth) { - const fixedStyle: ViewStyle = isHorizontal - ? {width: itemWidth, flexShrink: 1, overflow: 'hidden', flexDirection: 'row', justifyContent: 'center'} - : {width: itemWidth, maxWidth: '100%'}; - return ( - - {child} - - ); - } + const renderChild = useCallback((child: React.ReactNode, index: number) => { + if (itemsFit === ItemsFit.FIXED && itemWidth) { + const fixedStyle: ViewStyle = isHorizontal + ? {width: itemWidth, flexShrink: 1, overflow: 'hidden', flexDirection: 'row', justifyContent: 'center'} + : {width: itemWidth, maxWidth: '100%'}; + return ( + + {child} + + ); + } - if (isHorizontal && React.isValidElement(child) && itemsFit === ItemsFit.STRETCH) { - return ( - - {child} - - ); - } - return child; - }, - [itemsFit, itemWidth, isHorizontal] - ); + if (isHorizontal && React.isValidElement(child) && itemsFit === ItemsFit.STRETCH) { + return ( + + {child} + + ); + } + return child; + }, + [itemsFit, itemWidth, isHorizontal]); const childrenArray = React.Children.toArray(children).slice(0, 3).map(renderChild); @@ -240,20 +221,9 @@ const ScreenFooter = (props: ScreenFooterProps) => { ); }, [renderBackground, testID, contentContainerStyle, childrenArray]); - const Container = useMemo(() => { - return withoutAnimation ? View : Animated.View; - }, [withoutAnimation]); - - const containerStyle = useMemo(() => { - return withoutAnimation - ? [styles.container, containerStyleOverride] - : [styles.container, hoistedAnimatedStyle, containerStyleOverride]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [withoutAnimation, containerStyleOverride]); - - if (keyboardBehavior === KeyboardBehavior.HOISTED) { - return ( - + const renderKeyboardAwareFooter = useCallback(() => { + if (keyboardBehavior === 'hoisted') { + return ( { revealKeyboardInteractive onHeightChanged={setHeight} /> - - ); - } + ); + } else { + return renderFooterContent(); + } + }, [keyboardBehavior, renderFooterContent]); return ( - - {renderFooterContent()} + + {renderKeyboardAwareFooter()} ); }; @@ -277,13 +254,6 @@ const ScreenFooter = (props: ScreenFooterProps) => { ScreenFooter.displayName = 'ScreenFooter'; const styles = StyleSheet.create({ - container: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - zIndex: 50 - }, contentContainer: { paddingTop: Spacings.s4, paddingHorizontal: Spacings.s5, diff --git a/packages/react-native-ui-lib/src/components/screenFooter/screenFooter.api.json b/packages/react-native-ui-lib/src/components/screenFooter/screenFooter.api.json index 08b2008cf1..a81d34d717 100644 --- a/packages/react-native-ui-lib/src/components/screenFooter/screenFooter.api.json +++ b/packages/react-native-ui-lib/src/components/screenFooter/screenFooter.api.json @@ -55,13 +55,19 @@ { "name": "visible", "type": "boolean", - "description": "If true, the footer is visible. If false, it slides down", + "description": "If true, the footer is visible. If false, the footer is hidden using the configured animation type", "default": "true" }, + { + "name": "animationType", + "type": "ScreenFooterAnimationTypeProp", + "description": "The animation type for showing/hiding the footer [slide, fade, none]", + "default": "'slide'" + }, { "name": "animationDuration", "type": "number", - "description": "Duration of the show/hide animation in ms", + "description": "Duration of the show/hide animation in ms (sending 0 will disable the animation)", "default": "200" }, { diff --git a/packages/react-native-ui-lib/src/components/screenFooter/types.ts b/packages/react-native-ui-lib/src/components/screenFooter/types.ts index d316d9a9d8..dee4c1fa6f 100644 --- a/packages/react-native-ui-lib/src/components/screenFooter/types.ts +++ b/packages/react-native-ui-lib/src/components/screenFooter/types.ts @@ -40,7 +40,28 @@ export enum ScreenFooterShadow { SH30 = 'sh30' } -export interface ScreenFooterProps extends PropsWithChildren<{}> { +export enum ScreenFooterAnimation { + NONE = 'none', + SLIDE = 'slide', + FADE = 'fade' +} + +export type ScreenFooterAnimationTypeProp = ScreenFooterAnimation | `${ScreenFooterAnimation}`; + +export interface AnimatedFooterStyleProps { + /** + * The type of animation to use when showing or hiding the footer. + * @default 'slide' + */ + animationType?: ScreenFooterAnimationTypeProp; + /** + * Duration of the show/hide animation in ms (sending 0 will disable the animation). + * @default 200 + */ + animationDuration?: number; +} + +export interface ScreenFooterProps extends AnimatedFooterStyleProps, PropsWithChildren<{}> { /** * Used as testing identifier */ @@ -86,11 +107,6 @@ export interface ScreenFooterProps extends PropsWithChildren<{}> { * If true, the footer is visible. If false, it slides down. */ visible?: boolean; - /** - * Duration of the show/hide animation in ms. - * @default 200 - */ - animationDuration?: number; /** * If true, the footer will respect the safe area (add bottom padding) */ diff --git a/packages/react-native-ui-lib/src/components/screenFooter/useAnimatedFooterStyle.ts b/packages/react-native-ui-lib/src/components/screenFooter/useAnimatedFooterStyle.ts new file mode 100644 index 0000000000..cbdcecc1e2 --- /dev/null +++ b/packages/react-native-ui-lib/src/components/screenFooter/useAnimatedFooterStyle.ts @@ -0,0 +1,78 @@ +import {useAnimatedKeyboard, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import {StyleSheet, ViewStyle} from 'react-native'; +import {AnimatedFooterStyleProps, ScreenFooterProps} from './types'; +import {Constants} from '../../commons/new'; +import {useEffect, useMemo, useState} from 'react'; + +const androidVersion = Constants.getAndroidVersion(); +const useAnimatedFooterStyle = ( + props: AnimatedFooterStyleProps & + Pick +) => { + const { + animationType: animationTypeProp = 'slide', + animationDuration: animationDurationProp = 200, + keyboardBehavior, + visible, + isAndroidEdgeToEdge = !!androidVersion && androidVersion >= 35 ? true : undefined, + containerStyle: containerStyleOverride + } = props; + + const animationType = animationDurationProp === 0 ? 'none' : animationTypeProp; + const animationDuration = animationType === 'none' ? 0 : animationDurationProp; + + const keyboard = useAnimatedKeyboard({ + isNavigationBarTranslucentAndroid: isAndroidEdgeToEdge, + isStatusBarTranslucentAndroid: isAndroidEdgeToEdge + }); + + const [height, setHeight] = useState(0); + const animatedValue = useSharedValue(animationType === 'fade' && visible ? 1 : 0); + + useEffect(() => { + if (animationType === 'slide') { + animatedValue.value = withTiming(visible ? 0 : height, {duration: animationDuration}); + } else { + animatedValue.value = withTiming(visible ? 1 : 0, {duration: animationDuration}); + } + }, [visible, height, animationDuration, animatedValue, animationType]); + + const animatedStyle = useAnimatedStyle(() => { + let style: ViewStyle = {}; + let translateY = 0; + if (animationType === 'slide') { + translateY = animatedValue.value; + } else { + style = {opacity: animatedValue.value}; + } + + if (keyboardBehavior === 'sticky' && Constants.isAndroid) { + translateY += keyboard.height.value; + } + + if (animationType === 'slide' || translateY !== 0) { + style.transform = [{translateY}]; + } + + return style; + }); + + const containerStyle = useMemo(() => { + return [styles.container, animatedStyle, containerStyleOverride]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerStyleOverride]); + + return {containerStyle, setHeight}; +}; + +export default useAnimatedFooterStyle; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + zIndex: 50 + } +}); diff --git a/packages/react-native-ui-lib/src/index.ts b/packages/react-native-ui-lib/src/index.ts index 23f0cf087b..7261ee5d42 100644 --- a/packages/react-native-ui-lib/src/index.ts +++ b/packages/react-native-ui-lib/src/index.ts @@ -79,6 +79,7 @@ export { HorizontalItemsDistribution, ItemsFit, KeyboardBehavior, + ScreenFooterAnimationTypeProp, ScreenFooterShadow } from './components/screenFooter'; export {default as Gradient, GradientProps, GradientTypes} from './components/gradient';