Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions demo/src/screens/componentScreens/ScreenFooterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ScreenFooterLayouts,
ScreenFooterBackgrounds,
KeyboardBehavior,
ScreenFooterAnimationTypeProp,
FooterAlignment,
HorizontalItemsDistribution,
ItemsFit,
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -115,6 +122,7 @@ const ScreenFooterContent = () => {
const [layout, setLayout] = useState<ScreenFooterLayouts>(ScreenFooterLayouts.HORIZONTAL);
const [background, setBackground] = useState<ScreenFooterBackgrounds>(ScreenFooterBackgrounds.SOLID);
const [keyboardBehavior, setKeyboardBehavior] = useState<KeyboardBehavior>(KeyboardBehavior.STICKY);
const [animationType, setAnimationType] = useState<ScreenFooterAnimationTypeProp>('slide');
const [alignment, setAlignment] = useState<FooterAlignment>(FooterAlignment.CENTER);
const [horizontalAlignment, setHorizontalAlignment] = useState<FooterAlignment>(FooterAlignment.CENTER);
const [distribution, setDistribution] = useState<HorizontalItemsDistribution>(HorizontalItemsDistribution.STACK);
Expand Down Expand Up @@ -390,6 +398,21 @@ const ScreenFooterContent = () => {
</View>
</View>

{/* Animation type */}
<View marginB-s4>
<Text text70M marginB-s2>
Animation Type
</Text>
<View row>
<SegmentedControl
segments={ANIMATION_TYPE_OPTIONS}
initialIndex={ANIMATION_TYPE_OPTIONS.findIndex(opt => opt.value === animationType)}
onChangeIndex={index => setAnimationType(ANIMATION_TYPE_OPTIONS[index].value)}
/>
<View flex />
</View>
Comment on lines +412 to +413
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is minor, but you can remove these two Views and pass containerStyle={styles.rowContainer} to the SegmentedControl instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copy-pasted the other parts, technically it should be using ExampleScreenPresenter all over this

</View>

{/* Alignment (Cross Axis) */}
<View marginB-s4>
<Text text70M marginB-s2>
Expand Down Expand Up @@ -478,6 +501,7 @@ const ScreenFooterContent = () => {
layout={layout}
backgroundType={background}
keyboardBehavior={keyboardBehavior}
animationType={animationType}
alignment={alignment}
horizontalAlignment={horizontalAlignment}
horizontalItemsDistribution={distribution}
Expand Down
128 changes: 50 additions & 78 deletions packages/react-native-ui-lib/src/components/screenFooter/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -17,6 +18,7 @@ import {
HorizontalItemsDistribution,
ItemsFit,
KeyboardBehavior,
ScreenFooterAnimationTypeProp,
ScreenFooterShadow
} from './types';

Expand All @@ -28,9 +30,9 @@ export {
HorizontalItemsDistribution,
ItemsFit,
KeyboardBehavior,
ScreenFooterAnimationTypeProp,
ScreenFooterShadow
};
const androidVersion = Constants.getAndroidVersion();
const ScreenFooter = (props: ScreenFooterProps) => {
const {
testID,
Expand All @@ -44,40 +46,20 @@ 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,
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
});

const onLayout = useCallback((event: LayoutChangeEvent) => {
Expand Down Expand Up @@ -201,30 +183,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 (
<View key={index} style={fixedStyle}>
{child}
</View>
);
}
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 (
<View key={index} style={fixedStyle}>
{child}
</View>
);
}

if (isHorizontal && React.isValidElement(child) && itemsFit === ItemsFit.STRETCH) {
return (
<View flex row centerH key={index}>
{child}
</View>
);
}
return child;
},
[itemsFit, itemWidth, isHorizontal]
);
if (isHorizontal && React.isValidElement(child) && itemsFit === ItemsFit.STRETCH) {
return (
<View flex row centerH key={index}>
{child}
</View>
);
}
return child;
},
[itemsFit, itemWidth, isHorizontal]);

const childrenArray = React.Children.toArray(children).slice(0, 3).map(renderChild);

Expand All @@ -239,18 +219,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 : [styles.container, hoistedAnimatedStyle];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withoutAnimation]);

if (keyboardBehavior === KeyboardBehavior.HOISTED) {
return (
<Container style={containerStyle} pointerEvents={visible ? 'box-none' : 'none'}>
const renderKeyboardAwareFooter = useCallback(() => {
if (keyboardBehavior === 'hoisted') {
return (
<Keyboard.KeyboardAccessoryView
renderContent={renderFooterContent}
kbInputRef={undefined}
Expand All @@ -260,26 +231,27 @@ const ScreenFooter = (props: ScreenFooterProps) => {
revealKeyboardInteractive
onHeightChanged={setHeight}
/>
</Container>
);
}
);
} else {
return renderFooterContent();
}
}, [keyboardBehavior, renderFooterContent]);

return (
<Animated.View testID={testID} onLayout={onLayout} style={[styles.container, stickyAnimatedStyle]}>
{renderFooterContent()}
<Animated.View
testID={testID}
style={containerStyle}
onLayout={keyboardBehavior === 'hoisted' ? undefined : onLayout}
pointerEvents={keyboardBehavior === 'hoisted' ? (visible ? 'box-none' : 'none') : 'auto'}
Comment thread
lidord-wix marked this conversation as resolved.
Outdated
>
{renderKeyboardAwareFooter()}
</Animated.View>
);
};

ScreenFooter.displayName = 'ScreenFooter';

const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0
},
contentContainer: {
paddingTop: Spacings.s4,
paddingHorizontal: Spacings.s5,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,16 @@
"description": "If true, the footer is visible. If false, it slides down",
Comment thread
lidord-wix marked this conversation as resolved.
Outdated
"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"
},
{
Expand Down
28 changes: 22 additions & 6 deletions packages/react-native-ui-lib/src/components/screenFooter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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<ScreenFooterProps, 'keyboardBehavior' | 'visible' | 'isAndroidEdgeToEdge'>
) => {
const {
animationType: animationTypeProp = 'slide',
animationDuration: animationDurationProp = 200,
keyboardBehavior,
visible,
isAndroidEdgeToEdge = !!androidVersion && androidVersion >= 35 ? true : undefined
} = props;

const animationType = animationDurationProp === 0 ? 'none' : animationTypeProp;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

animationType (a plain JS value) is used inside useAnimatedStyle, but Reanimated worklet closures capture the value at creation time. Since animationType is derived from props and can change, the worklet will reference a stale value.
If the user switches from 'slide' to 'fade' at runtime, the worklet may still branch on the old value. Consider using a useSharedValue for animationType and updating it in a useEffect, so the worklet always reads the current value. Same concern applies to keyboardBehavior.

IMO it's not a common case but worth fixing it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the possible bug?
The animationType is changed in the screen and it works as expected.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It reproduces in the screen with Hide on Scroll

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When animationType='slide' and visible=false, the initial animatedValue is 0 — but for slide, hidden state means animatedValue = height. Since height starts at 0, the footer will briefly flash visible until the first layout fires and useEffect updates the value. This is likely a subtle flash-of-content bug on mount.
You can see it in the FloatingButtonScreen, change visible initial value to false and add animationType='slide'

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To fix this we'll probably need a bigger change like I've mentioned in the PR description.
#3987 by Ziv might fix it but it's riskier (bigger regression chance) IMO


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 (translateY !== 0) {
style.transform = [{translateY}];
}

return style;
});

const containerStyle = useMemo(() => {
return [styles.container, animatedStyle];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyboardBehavior]);

return {containerStyle, setHeight};
};

export default useAnimatedFooterStyle;

const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0
}
});
Loading