From 3dddaccf8c9f960d8a4fdadf5dddd59c53cd1c8e Mon Sep 17 00:00:00 2001 From: loc-nguyent-sts Date: Thu, 12 Feb 2026 16:33:06 +0700 Subject: [PATCH] feat: TextInput animated Label --- src/components/RadioButton/RadioButton.tsx | 13 +- src/components/TextInput/TextInput.tsx | 181 ++++++++++++++++++--- src/theme/components/TextInput.ts | 16 +- 3 files changed, 183 insertions(+), 27 deletions(-) diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx index 6a434e8..4ed0e0f 100644 --- a/src/components/RadioButton/RadioButton.tsx +++ b/src/components/RadioButton/RadioButton.tsx @@ -158,8 +158,8 @@ export const RadioButton = forwardRef( - {label} + style={[RadioButtonTheme.textContainerStyle, StyleSheet.flatten(textContainerStyle)]}> + {label} ) : null) @@ -170,7 +170,9 @@ export const RadioButton = forwardRef( } return ( - + ( disabled ?? RadioButtonTheme.disabled ? disableOpacity ?? RadioButtonTheme.disableOpacity : 1, borderWidth: RadioButtonTheme.borderWidth, }, - style ?? RadioButtonTheme.style, + RadioButtonTheme.style, + style, ])} onPress={handlePress} {...rest}> @@ -197,7 +200,7 @@ export const RadioButton = forwardRef( inner={inner} isActive={!!(value ?? isActive)} innerBackgroundColor={innerBackgroundColor ?? RadioButtonTheme.innerBackgroundColor ?? '#007AFF'} - style={innerContainerStyle ?? RadioButtonTheme.innerContainerStyle} + style={[RadioButtonTheme.innerContainerStyle, StyleSheet.flatten(innerContainerStyle)]} testID="circle" /> diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index ed438b6..1e644fa 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -1,4 +1,12 @@ -import React, {forwardRef, ForwardRefExoticComponent, useCallback, useImperativeHandle, useRef} from 'react' +import React, { + forwardRef, + ForwardRefExoticComponent, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' import type { StyleProp, TextInputProps as RNTextInputProperties, @@ -8,11 +16,11 @@ import type { TouchableOpacityProps, ViewProps, } from 'react-native' -import {TextInput as RNTextInput, TouchableOpacity, View} from 'react-native' +import {TextInput as RNTextInput, StyleSheet, TouchableOpacity, View} from 'react-native' +import Animated, {interpolate, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated' import styled from 'styled-components/native' import TextInputOutlined from './TextInputOutlined' import {CustomIcon, CustomIconProps, Error} from './components' -import {isIOS} from '../../helpers' import {useTheme} from '../../hooks' import TextInputFlat from './TextInputFlat' @@ -61,6 +69,15 @@ export interface TextInputProps extends RNTextInputProperties { /** If true, the text input will be focused when the user touches the input */ focusOnTouch?: boolean + + /** If true, the label will animate from placeholder position to top-left when focused or has value */ + animatedLabel?: boolean + + /** Distance between the label and the input */ + animatedLabelDistance?: number + + /** Style for the animated label */ + animatedLabelStyle?: StyleProp } interface CompoundedComponent @@ -77,6 +94,8 @@ export interface InputContainerProps { isFocused?: boolean } +const ANIMATION_DURATION = 150 + export const TextInput = forwardRef( ( { @@ -99,6 +118,12 @@ export const TextInput = forwardRef( onSubmitEditing, onBlur, focusOnTouch, + animatedLabel, + animatedLabelDistance, + animatedLabelStyle, + value, + defaultValue, + placeholder, ...rest }, ref, @@ -106,6 +131,30 @@ export const TextInput = forwardRef( const TextInputTheme = useTheme().components.TextInput const inputRef = useRef(null) + // Track focus state and internal value for animated label + const [isFocused, setIsFocused] = useState(false) + const [hasValue, setHasValue] = useState(!!value || !!defaultValue) + + // Shared value for label position (0 = placeholder position, 1 = top position) + const labelAnimatedValue = useSharedValue(!!value || !!defaultValue ? 1 : 0) + + // Update hasValue when controlled value changes + useEffect(() => { + if (value !== undefined) { + setHasValue(!!value) + } + }, [value]) + + // Animate label when focus or value changes + useEffect(() => { + if (animatedLabel && label) { + const shouldAnimate = isFocused || hasValue + labelAnimatedValue.value = withTiming(shouldAnimate ? 1 : 0, { + duration: ANIMATION_DURATION, + }) + } + }, [isFocused, hasValue, animatedLabel, label, labelAnimatedValue]) + useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => inputRef.current?.clear(), @@ -116,42 +165,115 @@ export const TextInput = forwardRef( inputRef.current?.focus() }, []) + const handleInputFocus = useCallback(() => { + setIsFocused(true) + onFocus?.() + }, [onFocus]) + + const handleInputBlur = useCallback(() => { + setIsFocused(false) + onBlur?.() + }, [onBlur]) + + const handleChangeText = useCallback( + (text: string) => { + setHasValue(!!text) + onChangeText?.(text) + }, + [onChangeText], + ) + const componentFocusOnTouch = focusOnTouch ?? TextInputTheme.focusOnTouch ?? false const ContainerComponent = componentFocusOnTouch ? (TouchableOpacity as React.JSXElementConstructor) : (View as React.JSXElementConstructor) + // Determine if we should show animated label + const showAnimatedLabel = animatedLabel && !!label + const animatedLabelDistanceValue = animatedLabelDistance ?? TextInputTheme.animatedLabelDistance ?? 12 + + // Animated styles for label using reanimated + const animatedLabelStyleTransform = useAnimatedStyle(() => { + if (!showAnimatedLabel) { + return {} + } + return { + transform: [ + { + translateY: interpolate(labelAnimatedValue.value, [0, 1], [0, -animatedLabelDistanceValue]), + }, + { + scale: interpolate(labelAnimatedValue.value, [0, 1], [1, 0.85]), + }, + ], + } + }, [showAnimatedLabel, animatedLabelDistanceValue, labelAnimatedValue]) + + // + const textInputBasedStyle: StyleProp = animatedLabel + ? {position: 'relative', top: animatedLabelDistanceValue} + : {} + + // When animatedLabel is true, placeholder should be empty + const effectivePlaceholder = showAnimatedLabel ? '' : placeholder + return ( - {!!label && ( - + {!!label && !showAnimatedLabel && ( + <Title + testID="test-title" + style={[TextInputTheme.labelStyle, StyleSheet.flatten(labelStyle)]} + {...labelProps}> {label} {!!isRequire && <StarText testID="test-startText"> *</StarText>} )} {!!leftComponent && leftComponent} - + + {showAnimatedLabel && ( + + + {label} + {!!isRequire && *} + + + )} + + {!!rightComponent && rightComponent} {!!errorText && } @@ -167,10 +289,27 @@ const TouchableContainer = styled.TouchableOpacity(({theme}) => ({ alignItems: 'center', })) +const InputWrapper = styled.View({ + flex: 1, + justifyContent: 'center', +}) + +const AnimatedLabelContainer = styled(Animated.View)({ + position: 'absolute', + left: 0, + right: 0, + transformOrigin: 'left center', + zIndex: 999, +}) + +const AnimatedLabelText = styled(Animated.Text)(({theme}) => ({ + fontSize: theme?.fontSizes?.sm, + color: theme?.colors?.textColor, +})) + const Title = styled.Text(({theme}) => ({ fontSize: theme?.fontSizes?.xs, color: theme?.colors?.textColor, - paddingLeft: isIOS ? 0 : theme?.spacing?.tiny, paddingBottom: theme?.spacing?.tiny, })) diff --git a/src/theme/components/TextInput.ts b/src/theme/components/TextInput.ts index 3db6ad9..e3ce06a 100644 --- a/src/theme/components/TextInput.ts +++ b/src/theme/components/TextInput.ts @@ -75,7 +75,16 @@ export type TextInputThemeProps = Pick } export const TextInputTheme: TextInputThemeProps = { @@ -112,4 +121,9 @@ export const TextInputTheme: TextInputThemeProps = { labelFontSize: 14, errorFontSize: 12, focusOnTouch: false, + animatedLabelDistance: 12, + animatedLabelStyle: { + fontSize: 16, + color: base.colors.black, + }, }