Composer - add animated bottom border (#4325)

* start adding bottom border (wip)

* add content change listener

* add layout listener and move to hook

* remove logs

* use square-er image icon

* visually align bottom bar icons

* reduce keyboard vertical offset slightly

* only add border to top/bottom

* run worklet function on UI thread
zio/stable
Samuel Newman 2024-06-04 02:49:50 +03:00 committed by GitHub
parent 3b55f61d5f
commit 891b432ead
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 130 additions and 28 deletions

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 514 B

View File

@ -1,5 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE' import {createSinglePathSVG} from './TEMPLATE'
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
}) })

View File

@ -9,6 +9,7 @@ import React, {
import { import {
ActivityIndicator, ActivityIndicator,
Keyboard, Keyboard,
LayoutChangeEvent,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
@ -19,6 +20,7 @@ import {
} from 'react-native-keyboard-controller' } from 'react-native-keyboard-controller'
import Animated, { import Animated, {
interpolateColor, interpolateColor,
runOnUI,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
@ -170,22 +172,6 @@ export const ComposePost = observer(function ComposePost({
[insets, isKeyboardVisible], [insets, isKeyboardVisible],
) )
const hasScrolled = useSharedValue(0)
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
hasScrolled.value = withTiming(event.contentOffset.y > 0 ? 1 : 0)
},
})
const topBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderColor: interpolateColor(
hasScrolled.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
const onPressCancel = useCallback(() => { const onPressCancel = useCallback(() => {
if (graphemeLength > 0 || !gallery.isEmpty) { if (graphemeLength > 0 || !gallery.isEmpty) {
closeAllDialogs() closeAllDialogs()
@ -395,13 +381,21 @@ export const ComposePost = observer(function ComposePost({
[setExtLink], [setExtLink],
) )
const {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
} = useAnimatedBorders()
return ( return (
<> <>
<KeyboardAvoidingView <KeyboardAvoidingView
testID="composePostView" testID="composePostView"
behavior="padding" behavior="padding"
style={a.flex_1} style={a.flex_1}
keyboardVerticalOffset={replyTo ? 120 : isAndroid ? 180 : 150}> keyboardVerticalOffset={replyTo ? 110 : isAndroid ? 180 : 140}>
<View <View
style={[a.flex_1, viewStyles]} style={[a.flex_1, viewStyles]}
aria-modal aria-modal
@ -509,7 +503,9 @@ export const ComposePost = observer(function ComposePost({
<Animated.ScrollView <Animated.ScrollView
onScroll={scrollHandler} onScroll={scrollHandler}
style={styles.scrollView} style={styles.scrollView}
keyboardShouldPersistTaps="always"> keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<View <View
@ -575,7 +571,11 @@ export const ComposePost = observer(function ComposePost({
<KeyboardStickyView <KeyboardStickyView
offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}> offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
{replyTo ? null : ( {replyTo ? null : (
<ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} /> <ThreadgateBtn
threadgate={threadgate}
onChange={setThreadgate}
style={bottomBarAnimatedStyle}
/>
)} )}
<View <View
style={[ style={[
@ -625,10 +625,108 @@ export function useComposerCancelRef() {
return useRef<CancelRef>(null) return useRef<CancelRef>(null)
} }
function useAnimatedBorders() {
const t = useTheme()
const hasScrolledTop = useSharedValue(0)
const hasScrolledBottom = useSharedValue(0)
const contentOffset = useSharedValue(0)
const scrollViewHeight = useSharedValue(Infinity)
const contentHeight = useSharedValue(0)
/**
* Make sure to run this on the UI thread!
*/
const showHideBottomBorder = useCallback(
({
newContentHeight,
newContentOffset,
newScrollViewHeight,
}: {
newContentHeight?: number
newContentOffset?: number
newScrollViewHeight?: number
}) => {
'worklet'
if (typeof newContentHeight === 'number')
contentHeight.value = newContentHeight
if (typeof newContentOffset === 'number')
contentOffset.value = newContentOffset
if (typeof newScrollViewHeight === 'number')
scrollViewHeight.value = newScrollViewHeight
hasScrolledBottom.value = withTiming(
contentHeight.value - contentOffset.value >= scrollViewHeight.value
? 1
: 0,
)
},
[contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom],
)
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0)
// already on UI thread
showHideBottomBorder({
newContentOffset: event.contentOffset.y,
newContentHeight: event.contentSize.height,
newScrollViewHeight: event.layoutMeasurement.height,
})
},
})
const onScrollViewContentSizeChange = useCallback(
(_width: number, height: number) => {
runOnUI(showHideBottomBorder)({
newContentHeight: height,
})
},
[showHideBottomBorder],
)
const onScrollViewLayout = useCallback(
(evt: LayoutChangeEvent) => {
runOnUI(showHideBottomBorder)({
newScrollViewHeight: evt.nativeEvent.layout.height,
})
},
[showHideBottomBorder],
)
const topBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderBottomWidth: hairlineWidth,
borderColor: interpolateColor(
hasScrolledTop.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderTopWidth: hairlineWidth,
borderColor: interpolateColor(
hasScrolledBottom.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
return {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
}
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
topbar: { topbar: {},
borderBottomWidth: StyleSheet.hairlineWidth,
},
topbarDesktop: { topbarDesktop: {
paddingTop: 10, paddingTop: 10,
paddingBottom: 10, paddingBottom: 10,
@ -698,7 +796,8 @@ const styles = StyleSheet.create({
bottomBar: { bottomBar: {
flexDirection: 'row', flexDirection: 'row',
paddingVertical: 4, paddingVertical: 4,
paddingLeft: 8, // should be 8 but due to visual alignment we have to fudge it
paddingLeft: 7,
paddingRight: 16, paddingRight: 16,
alignItems: 'center', alignItems: 'center',
borderTopWidth: hairlineWidth, borderTopWidth: hairlineWidth,

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {Keyboard, View} from 'react-native' import {Keyboard, StyleProp, ViewStyle} from 'react-native'
import Animated, {AnimatedStyle} from 'react-native-reanimated'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -16,9 +17,11 @@ import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
export function ThreadgateBtn({ export function ThreadgateBtn({
threadgate, threadgate,
onChange, onChange,
style,
}: { }: {
threadgate: ThreadgateSetting[] threadgate: ThreadgateSetting[]
onChange: (v: ThreadgateSetting[]) => void onChange: (v: ThreadgateSetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>>
}) { }) {
const {track} = useAnalytics() const {track} = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
@ -46,7 +49,7 @@ export function ThreadgateBtn({
: _(msg`Some people can reply`) : _(msg`Some people can reply`)
return ( return (
<View style={[a.flex_row, a.py_xs, a.px_sm, t.atoms.bg]}> <Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
<Button <Button
variant="solid" variant="solid"
color="secondary" color="secondary"
@ -59,6 +62,6 @@ export function ThreadgateBtn({
/> />
<ButtonText>{label}</ButtonText> <ButtonText>{label}</ButtonText>
</Button> </Button>
</View> </Animated.View>
) )
} }