AutosizedTextarea is good

This commit is contained in:
Eric Bailey
2026-04-04 13:22:25 -05:00
parent 0c386408a3
commit 9f3bd40d1d
2 changed files with 86 additions and 66 deletions
+76 -63
View File
@@ -6,7 +6,7 @@ import {
} from 'react-native' } from 'react-native'
import {mergeRefs} from '#/lib/merge-refs' import {mergeRefs} from '#/lib/merge-refs'
import {atoms as a, extractPadding, flatten, useAlf, web} from '#/alf' import {atoms as a, extractPadding, useAlf, web} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography' import {normalizeTextStyles} from '#/alf/typography'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
@@ -19,7 +19,7 @@ export function AutosizedTextarea({
onChangeText: onChangeTextOuter, onChangeText: onChangeTextOuter,
onContentSizeChange: onContentSizeChangeOuter, onContentSizeChange: onContentSizeChangeOuter,
style, style: outerStyle,
...rest ...rest
}: Omit<TextInputProps, 'multiline'> & { }: Omit<TextInputProps, 'multiline'> & {
ref?: React.Ref<TextInput> ref?: React.Ref<TextInput>
@@ -28,82 +28,94 @@ export function AutosizedTextarea({
maxRows?: number maxRows?: number
onUpdateHeight?: (height: number) => void onUpdateHeight?: (height: number) => void
}) { }) {
const textInputRef = useRef<TextInput>(null)
const {theme: t, fonts} = useAlf() const {theme: t, fonts} = useAlf()
const {processedStyle, minHeight, maxHeight} = useMemo(() => { const internalRef = useRef<TextInput>(null)
const fs = flatten(style) const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
const ts = normalizeTextStyles( useMemo(() => {
[a.text_md, a.leading_snug, t.atoms.text, fs], const normalizedStyles = normalizeTextStyles(
{ [a.text_md, a.leading_snug, t.atoms.text, outerStyle],
fontScale: fonts.scaleMultiplier, {
fontFamily: fonts.family, fontScale: fonts.scaleMultiplier,
flags: {}, fontFamily: fonts.family,
}, flags: {},
) },
const lineHeight = ts.lineHeight || 20 )
const padding = extractPadding(fs ?? {}) const lineHeight = normalizedStyles.lineHeight || 20
const verticalSpace = padding.paddingTop + padding.paddingBottom const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {})
const mh = lineHeight * minRows + verticalSpace const verticalContentPadding = paddingTop + paddingBottom
const xh = maxRows ? lineHeight * maxRows + verticalSpace : Infinity const minInputHeight = lineHeight * minRows + verticalContentPadding
/* const maxInputHeight = maxRows
* iOS: minHeight/maxHeight works fine natively. ? lineHeight * maxRows + verticalContentPadding
* Web + Android: we set an explicit initial height and resize dynamically : Infinity
* (web via DOM measurement, Android via onContentSizeChange state).
*
* iOS also seems to need 1px headroom to actually expand to the correct
* maxHeight
*/
const tas = IS_IOS ? {minHeight: mh, maxHeight: xh + 1} : {height: mh}
return { /*
processedStyle: { * iOS: minHeight/maxHeight works fine natively.
...ts, * Web + Android: we set an explicit initial height and resize dynamically
...tas, * (web via DOM measurement, Android via onContentSizeChange state).
}, *
minHeight: mh, * iOS also seems to need 1px headroom to actually expand to the correct
maxHeight: xh, * maxHeight
} */
}, [t, fonts, style, minRows, maxRows]) const heightConstraints = IS_IOS
? {minHeight: minInputHeight, maxHeight: maxInputHeight + 1}
: {height: minInputHeight}
return {
style: {
...normalizedStyles,
...heightConstraints,
},
minInputHeight,
maxInputHeight,
verticalContentPadding,
}
}, [t, fonts, outerStyle, minRows, maxRows])
/* /*
* On Android, multiline TextInput oscillates between slightly different * Web handling
* contentSize values on consecutive layout passes (sub-pixel rounding).
* This causes visible jumpiness when using minHeight/maxHeight. Instead,
* we drive the height explicitly and ceil the value to stabilize it.
*/ */
const [androidInputHeight, setAndroidInputHeight] = useState(minHeight) const prevWebHeight = useRef(0)
const handleResizeWeb = () => {
const prevHeight = useRef(0) const el = internalRef.current as unknown as HTMLTextAreaElement
const resizeWeb = () => {
const el = textInputRef.current as unknown as HTMLTextAreaElement
if (!el) return if (!el) return
// collapse to get natural scroll height
el.style.height = '0px' el.style.height = '0px'
const scrollHeight = el.scrollHeight const scrollHeight = Math.ceil(el.scrollHeight)
const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight) const nextHeight = Math.min(
Math.max(scrollHeight, minInputHeight),
maxInputHeight,
)
// immediately update height to prevent flicker
el.style.height = `${nextHeight}px` el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden' el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) { if (nextHeight !== prevWebHeight.current) {
prevHeight.current = nextHeight prevWebHeight.current = nextHeight
onUpdateHeight?.(nextHeight) onUpdateHeight?.(nextHeight)
} }
} }
const onChangeText = (text: string) => { const onChangeText = (text: string) => {
if (IS_WEB) resizeWeb() if (IS_WEB) handleResizeWeb()
onChangeTextOuter?.(text) onChangeTextOuter?.(text)
} }
/* /*
* Native height tracking: on Android we ceil to stabilize sub-pixel * Native handling
* oscillation and drive height via state; on iOS we just notify. *
* We track the height as state on native, and on Android, we use this to
* directly drive the `height`.
*/ */
const [nativeHeight, setNativeHeight] = useState(minInputHeight)
const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => { const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => {
const h = Math.ceil(e.nativeEvent.contentSize.height) const contentSize = Math.ceil(e.nativeEvent.contentSize.height)
const nextHeight = Math.min(Math.max(h, minHeight), maxHeight) // ios reports the content size without padding
const height = IS_IOS ? contentSize + verticalContentPadding : contentSize
const nextHeight = Math.min(
Math.max(height, minInputHeight),
maxInputHeight,
)
if (nextHeight !== prevHeight.current) { if (nextHeight !== nativeHeight) {
prevHeight.current = nextHeight setNativeHeight(nextHeight)
if (IS_ANDROID) setAndroidInputHeight(nextHeight)
onUpdateHeight?.(nextHeight) onUpdateHeight?.(nextHeight)
} }
@@ -119,10 +131,10 @@ export function AutosizedTextarea({
placeholder={label} placeholder={label}
keyboardAppearance={t.scheme} keyboardAppearance={t.scheme}
submitBehavior="newline" submitBehavior="newline"
scrollEnabled={nativeHeight >= maxInputHeight}
style={[ style={[
a.relative, a.relative,
a.border_0, a.border_0,
IS_ANDROID ? {height: androidInputHeight} : {},
{ {
textAlignVertical: 'top', textAlignVertical: 'top',
includeFontPadding: false, includeFontPadding: false,
@@ -133,14 +145,15 @@ export function AutosizedTextarea({
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}), }),
processedStyle, style,
IS_ANDROID ? {height: nativeHeight} : {},
]} ]}
{...rest} {...rest}
ref={mergeRefs([ ref={mergeRefs([
(node: TextInput | null) => { (node: TextInput | null) => {
textInputRef.current = node internalRef.current = node
// bop resize on first render // bop resize on first render
if (IS_WEB && node) resizeWeb() if (IS_WEB && node) handleResizeWeb()
}, },
ref, ref,
])} ])}
+10 -3
View File
@@ -3,11 +3,13 @@ import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {PressableScale} from '#/lib/custom-animations/PressableScale' import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform' import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
import {type NavigationProp} from '#/lib/routes/types'
import {emitSoftReset} from '#/state/events' import {emitSoftReset} from '#/state/events'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
@@ -17,7 +19,7 @@ import {ButtonIcon} from '#/components/Button'
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag' import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {IS_LIQUID_GLASS} from '#/env' import {IS_DEV, IS_LIQUID_GLASS} from '#/env'
export function HomeHeaderLayoutMobile({ export function HomeHeaderLayoutMobile({
children, children,
@@ -32,6 +34,7 @@ export function HomeHeaderLayoutMobile({
const headerMinimalShellTransform = useMinimalShellHeaderTransform() const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession() const {hasSession} = useSession()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const {navigate} = useNavigation<NavigationProp>()
return ( return (
<Animated.View <Animated.View
@@ -59,8 +62,12 @@ export function HomeHeaderLayoutMobile({
<PressableScale <PressableScale
targetScale={0.9} targetScale={0.9}
onPress={() => { onPress={() => {
playHaptic('Light') if (IS_DEV) {
emitSoftReset() navigate('Debug')
} else {
playHaptic('Light')
emitSoftReset()
}
}}> }}>
<Logo width={30} /> <Logo width={30} />
</PressableScale> </PressableScale>