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'
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 {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
@@ -19,7 +19,7 @@ export function AutosizedTextarea({
onChangeText: onChangeTextOuter,
onContentSizeChange: onContentSizeChangeOuter,
style,
style: outerStyle,
...rest
}: Omit<TextInputProps, 'multiline'> & {
ref?: React.Ref<TextInput>
@@ -28,82 +28,94 @@ export function AutosizedTextarea({
maxRows?: number
onUpdateHeight?: (height: number) => void
}) {
const textInputRef = useRef<TextInput>(null)
const {theme: t, fonts} = useAlf()
const {processedStyle, minHeight, maxHeight} = useMemo(() => {
const fs = flatten(style)
const ts = normalizeTextStyles(
[a.text_md, a.leading_snug, t.atoms.text, fs],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const lineHeight = ts.lineHeight || 20
const padding = extractPadding(fs ?? {})
const verticalSpace = padding.paddingTop + padding.paddingBottom
const mh = lineHeight * minRows + verticalSpace
const xh = maxRows ? lineHeight * maxRows + verticalSpace : Infinity
/*
* iOS: minHeight/maxHeight works fine natively.
* Web + Android: we set an explicit initial height and resize dynamically
* (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}
const internalRef = useRef<TextInput>(null)
const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
useMemo(() => {
const normalizedStyles = normalizeTextStyles(
[a.text_md, a.leading_snug, t.atoms.text, outerStyle],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const lineHeight = normalizedStyles.lineHeight || 20
const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {})
const verticalContentPadding = paddingTop + paddingBottom
const minInputHeight = lineHeight * minRows + verticalContentPadding
const maxInputHeight = maxRows
? lineHeight * maxRows + verticalContentPadding
: Infinity
return {
processedStyle: {
...ts,
...tas,
},
minHeight: mh,
maxHeight: xh,
}
}, [t, fonts, style, minRows, maxRows])
/*
* iOS: minHeight/maxHeight works fine natively.
* Web + Android: we set an explicit initial height and resize dynamically
* (web via DOM measurement, Android via onContentSizeChange state).
*
* iOS also seems to need 1px headroom to actually expand to the correct
* maxHeight
*/
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
* 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.
* Web handling
*/
const [androidInputHeight, setAndroidInputHeight] = useState(minHeight)
const prevHeight = useRef(0)
const resizeWeb = () => {
const el = textInputRef.current as unknown as HTMLTextAreaElement
const prevWebHeight = useRef(0)
const handleResizeWeb = () => {
const el = internalRef.current as unknown as HTMLTextAreaElement
if (!el) return
// collapse to get natural scroll height
el.style.height = '0px'
const scrollHeight = el.scrollHeight
const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight)
const scrollHeight = Math.ceil(el.scrollHeight)
const nextHeight = Math.min(
Math.max(scrollHeight, minInputHeight),
maxInputHeight,
)
// immediately update height to prevent flicker
el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight
el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden'
if (nextHeight !== prevWebHeight.current) {
prevWebHeight.current = nextHeight
onUpdateHeight?.(nextHeight)
}
}
const onChangeText = (text: string) => {
if (IS_WEB) resizeWeb()
if (IS_WEB) handleResizeWeb()
onChangeTextOuter?.(text)
}
/*
* Native height tracking: on Android we ceil to stabilize sub-pixel
* oscillation and drive height via state; on iOS we just notify.
* Native handling
*
* 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 h = Math.ceil(e.nativeEvent.contentSize.height)
const nextHeight = Math.min(Math.max(h, minHeight), maxHeight)
const contentSize = Math.ceil(e.nativeEvent.contentSize.height)
// 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) {
prevHeight.current = nextHeight
if (IS_ANDROID) setAndroidInputHeight(nextHeight)
if (nextHeight !== nativeHeight) {
setNativeHeight(nextHeight)
onUpdateHeight?.(nextHeight)
}
@@ -119,10 +131,10 @@ export function AutosizedTextarea({
placeholder={label}
keyboardAppearance={t.scheme}
submitBehavior="newline"
scrollEnabled={nativeHeight >= maxInputHeight}
style={[
a.relative,
a.border_0,
IS_ANDROID ? {height: androidInputHeight} : {},
{
textAlignVertical: 'top',
includeFontPadding: false,
@@ -133,14 +145,15 @@ export function AutosizedTextarea({
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}),
processedStyle,
style,
IS_ANDROID ? {height: nativeHeight} : {},
]}
{...rest}
ref={mergeRefs([
(node: TextInput | null) => {
textInputRef.current = node
internalRef.current = node
// bop resize on first render
if (IS_WEB && node) resizeWeb()
if (IS_WEB && node) handleResizeWeb()
},
ref,
])}
+10 -3
View File
@@ -3,11 +3,13 @@ import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_10} from '#/lib/constants'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
import {type NavigationProp} from '#/lib/routes/types'
import {emitSoftReset} from '#/state/events'
import {useSession} from '#/state/session'
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 * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {IS_LIQUID_GLASS} from '#/env'
import {IS_DEV, IS_LIQUID_GLASS} from '#/env'
export function HomeHeaderLayoutMobile({
children,
@@ -32,6 +34,7 @@ export function HomeHeaderLayoutMobile({
const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession()
const playHaptic = useHaptics()
const {navigate} = useNavigation<NavigationProp>()
return (
<Animated.View
@@ -59,8 +62,12 @@ export function HomeHeaderLayoutMobile({
<PressableScale
targetScale={0.9}
onPress={() => {
playHaptic('Light')
emitSoftReset()
if (IS_DEV) {
navigate('Debug')
} else {
playHaptic('Light')
emitSoftReset()
}
}}>
<Logo width={30} />
</PressableScale>