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
This commit is contained in:
		
							parent
							
								
									3b55f61d5f
								
							
						
					
					
						commit
						891b432ead
					
				
					 4 changed files with 130 additions and 28 deletions
				
			
		| 
						 | 
				
			
			@ -9,6 +9,7 @@ import React, {
 | 
			
		|||
import {
 | 
			
		||||
  ActivityIndicator,
 | 
			
		||||
  Keyboard,
 | 
			
		||||
  LayoutChangeEvent,
 | 
			
		||||
  StyleSheet,
 | 
			
		||||
  TouchableOpacity,
 | 
			
		||||
  View,
 | 
			
		||||
| 
						 | 
				
			
			@ -19,6 +20,7 @@ import {
 | 
			
		|||
} from 'react-native-keyboard-controller'
 | 
			
		||||
import Animated, {
 | 
			
		||||
  interpolateColor,
 | 
			
		||||
  runOnUI,
 | 
			
		||||
  useAnimatedStyle,
 | 
			
		||||
  useSharedValue,
 | 
			
		||||
  withTiming,
 | 
			
		||||
| 
						 | 
				
			
			@ -170,22 +172,6 @@ export const ComposePost = observer(function ComposePost({
 | 
			
		|||
    [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(() => {
 | 
			
		||||
    if (graphemeLength > 0 || !gallery.isEmpty) {
 | 
			
		||||
      closeAllDialogs()
 | 
			
		||||
| 
						 | 
				
			
			@ -395,13 +381,21 @@ export const ComposePost = observer(function ComposePost({
 | 
			
		|||
    [setExtLink],
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    scrollHandler,
 | 
			
		||||
    onScrollViewContentSizeChange,
 | 
			
		||||
    onScrollViewLayout,
 | 
			
		||||
    topBarAnimatedStyle,
 | 
			
		||||
    bottomBarAnimatedStyle,
 | 
			
		||||
  } = useAnimatedBorders()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <KeyboardAvoidingView
 | 
			
		||||
        testID="composePostView"
 | 
			
		||||
        behavior="padding"
 | 
			
		||||
        style={a.flex_1}
 | 
			
		||||
        keyboardVerticalOffset={replyTo ? 120 : isAndroid ? 180 : 150}>
 | 
			
		||||
        keyboardVerticalOffset={replyTo ? 110 : isAndroid ? 180 : 140}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[a.flex_1, viewStyles]}
 | 
			
		||||
          aria-modal
 | 
			
		||||
| 
						 | 
				
			
			@ -509,7 +503,9 @@ export const ComposePost = observer(function ComposePost({
 | 
			
		|||
          <Animated.ScrollView
 | 
			
		||||
            onScroll={scrollHandler}
 | 
			
		||||
            style={styles.scrollView}
 | 
			
		||||
            keyboardShouldPersistTaps="always">
 | 
			
		||||
            keyboardShouldPersistTaps="always"
 | 
			
		||||
            onContentSizeChange={onScrollViewContentSizeChange}
 | 
			
		||||
            onLayout={onScrollViewLayout}>
 | 
			
		||||
            {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
 | 
			
		||||
 | 
			
		||||
            <View
 | 
			
		||||
| 
						 | 
				
			
			@ -575,7 +571,11 @@ export const ComposePost = observer(function ComposePost({
 | 
			
		|||
      <KeyboardStickyView
 | 
			
		||||
        offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
 | 
			
		||||
        {replyTo ? null : (
 | 
			
		||||
          <ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} />
 | 
			
		||||
          <ThreadgateBtn
 | 
			
		||||
            threadgate={threadgate}
 | 
			
		||||
            onChange={setThreadgate}
 | 
			
		||||
            style={bottomBarAnimatedStyle}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
| 
						 | 
				
			
			@ -625,10 +625,108 @@ export function useComposerCancelRef() {
 | 
			
		|||
  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({
 | 
			
		||||
  topbar: {
 | 
			
		||||
    borderBottomWidth: StyleSheet.hairlineWidth,
 | 
			
		||||
  },
 | 
			
		||||
  topbar: {},
 | 
			
		||||
  topbarDesktop: {
 | 
			
		||||
    paddingTop: 10,
 | 
			
		||||
    paddingBottom: 10,
 | 
			
		||||
| 
						 | 
				
			
			@ -698,7 +796,8 @@ const styles = StyleSheet.create({
 | 
			
		|||
  bottomBar: {
 | 
			
		||||
    flexDirection: 'row',
 | 
			
		||||
    paddingVertical: 4,
 | 
			
		||||
    paddingLeft: 8,
 | 
			
		||||
    // should be 8 but due to visual alignment we have to fudge it
 | 
			
		||||
    paddingLeft: 7,
 | 
			
		||||
    paddingRight: 16,
 | 
			
		||||
    alignItems: 'center',
 | 
			
		||||
    borderTopWidth: hairlineWidth,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
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 {useLingui} from '@lingui/react'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -16,9 +17,11 @@ import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
 | 
			
		|||
export function ThreadgateBtn({
 | 
			
		||||
  threadgate,
 | 
			
		||||
  onChange,
 | 
			
		||||
  style,
 | 
			
		||||
}: {
 | 
			
		||||
  threadgate: ThreadgateSetting[]
 | 
			
		||||
  onChange: (v: ThreadgateSetting[]) => void
 | 
			
		||||
  style?: StyleProp<AnimatedStyle<ViewStyle>>
 | 
			
		||||
}) {
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
| 
						 | 
				
			
			@ -46,7 +49,7 @@ export function ThreadgateBtn({
 | 
			
		|||
    : _(msg`Some people can reply`)
 | 
			
		||||
 | 
			
		||||
  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
 | 
			
		||||
        variant="solid"
 | 
			
		||||
        color="secondary"
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +62,6 @@ export function ThreadgateBtn({
 | 
			
		|||
        />
 | 
			
		||||
        <ButtonText>{label}</ButtonText>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </View>
 | 
			
		||||
    </Animated.View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue