AutosizedTextarea is good
This commit is contained in:
@@ -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,
|
||||||
])}
|
])}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user