Checkpoint autosize

This commit is contained in:
Eric Bailey
2026-04-03 17:26:57 -05:00
parent 12ccb3479f
commit 0c386408a3
2 changed files with 55 additions and 54 deletions
+41 -52
View File
@@ -1,4 +1,4 @@
import {useEffect, useMemo, useRef, useState} from 'react' import {useMemo, useRef, useState} from 'react'
import { import {
TextInput, TextInput,
type TextInputContentSizeChangeEvent, type TextInputContentSizeChangeEvent,
@@ -46,16 +46,14 @@ export function AutosizedTextarea({
const mh = lineHeight * minRows + verticalSpace const mh = lineHeight * minRows + verticalSpace
const xh = maxRows ? lineHeight * maxRows + verticalSpace : Infinity const xh = maxRows ? lineHeight * maxRows + verticalSpace : Infinity
/* /*
* On web, we set an initial height and auto-resize via DOM measurement. * iOS: minHeight/maxHeight works fine natively.
* On Android, we drive height explicitly via onContentSizeChange to avoid * Web + Android: we set an explicit initial height and resize dynamically
* sub-pixel oscillation that causes layout jumpiness. * (web via DOM measurement, Android via onContentSizeChange state).
* On iOS, minHeight/maxHeight works fine natively. *
* iOS also seems to need 1px headroom to actually expand to the correct
* maxHeight
*/ */
const tas = IS_WEB const tas = IS_IOS ? {minHeight: mh, maxHeight: xh + 1} : {height: mh}
? {height: mh}
: IS_ANDROID
? {height: mh}
: {minHeight: mh, maxHeight: xh}
return { return {
processedStyle: { processedStyle: {
@@ -76,57 +74,42 @@ export function AutosizedTextarea({
const [androidInputHeight, setAndroidInputHeight] = useState(minHeight) const [androidInputHeight, setAndroidInputHeight] = useState(minHeight)
const prevHeight = useRef(0) const prevHeight = useRef(0)
const onChangeText = (text: string) => { const resizeWeb = () => {
if (IS_WEB) { const el = textInputRef.current as unknown as HTMLTextAreaElement
const el = textInputRef.current as unknown as HTMLTextAreaElement if (!el) return
if (el) { el.style.height = '0px'
el.style.height = '0px' const scrollHeight = el.scrollHeight
const scrollHeight = el.scrollHeight const nextHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight)
const nextHeight = Math.min( el.style.height = `${nextHeight}px`
Math.max(scrollHeight, minHeight), el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
maxHeight, if (nextHeight !== prevHeight.current) {
) prevHeight.current = nextHeight
el.style.height = `${nextHeight}px` onUpdateHeight?.(nextHeight)
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight
onUpdateHeight?.(nextHeight)
}
}
} else if (IS_IOS) {
textInputRef.current?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) {
prevHeight.current = h
onUpdateHeight?.(h)
}
})
} }
}
const onChangeText = (text: string) => {
if (IS_WEB) resizeWeb()
onChangeTextOuter?.(text) onChangeTextOuter?.(text)
} }
/*
* Native height tracking: on Android we ceil to stabilize sub-pixel
* oscillation and drive height via state; on iOS we just notify.
*/
const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => { const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => {
if (IS_ANDROID) { const h = Math.ceil(e.nativeEvent.contentSize.height)
const h = Math.ceil(e.nativeEvent.contentSize.height) const nextHeight = Math.min(Math.max(h, minHeight), maxHeight)
const nextHeight = Math.min(Math.max(h, minHeight), maxHeight)
if (nextHeight !== androidInputHeight) { if (nextHeight !== prevHeight.current) {
setAndroidInputHeight(nextHeight) prevHeight.current = nextHeight
} if (IS_ANDROID) setAndroidInputHeight(nextHeight)
onUpdateHeight?.(nextHeight)
} }
onContentSizeChangeOuter?.(e) onContentSizeChangeOuter?.(e)
} }
/*
* On Android, height is driven by onContentSizeChange (see the TextInput
* below), so we update the autocomplete position when that height changes.
*/
useEffect(() => {
if (IS_ANDROID) {
onUpdateHeight?.(androidInputHeight)
}
}, [androidInputHeight, onUpdateHeight])
return ( return (
<TextInput <TextInput
multiline multiline
@@ -149,12 +132,18 @@ export function AutosizedTextarea({
outline: 'none', outline: 'none',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
overscrollBehavior: 'none',
}), }),
processedStyle, processedStyle,
]} ]}
{...rest} {...rest}
ref={mergeRefs([textInputRef, ref])} ref={mergeRefs([
(node: TextInput | null) => {
textInputRef.current = node
// bop resize on first render
if (IS_WEB && node) resizeWeb()
},
ref,
])}
onChangeText={onChangeText} onChangeText={onChangeText}
onContentSizeChange={onContentSizeChange} onContentSizeChange={onContentSizeChange}
/> />
+14 -2
View File
@@ -40,7 +40,7 @@ export function Forms() {
<View style={[a.gap_md, a.align_start, a.w_full]}> <View style={[a.gap_md, a.align_start, a.w_full]}>
<AutosizedTextarea <AutosizedTextarea
label="AutosizedTextarea minRows=1 maxRows=5" label="minRows=1 maxRows=5"
style={[ style={[
a.w_full, a.w_full,
a.p_md, a.p_md,
@@ -51,7 +51,19 @@ export function Forms() {
maxRows={5} maxRows={5}
/> />
<AutosizedTextarea <AutosizedTextarea
label="AutosizedTextarea minRows=3 maxRows=10" label="defaultValue minRows=1 maxRows=2"
style={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_medium,
]}
maxRows={2}
defaultValue="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec auctor, nisl eget ultricies lacinia, nunc nisl aliquam nisl, eget aliquam nunc nisl eget nunc."
/>
<AutosizedTextarea
label="minRows=3 maxRows=10"
style={[ style={[
a.w_full, a.w_full,
a.p_md, a.p_md,