experimental gesture view

This commit is contained in:
Hailey
2024-09-19 02:14:13 -07:00
parent cbc7cd0808
commit 1dc10a5ead
3 changed files with 593 additions and 156 deletions
@@ -0,0 +1,397 @@
import React from 'react'
import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
interpolateColor,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated'
import {useHaptics} from 'lib/haptics'
import absoluteFill = StyleSheet.absoluteFill
interface GestureAction {
color: ColorValue
action: () => void
threshold: number
icon: React.ElementType
}
interface GestureActions {
leftFirst?: GestureAction
leftSecond?: GestureAction
rightFirst?: GestureAction
rightSecond?: GestureAction
}
const MAX_WIDTH = Dimensions.get('screen').width
const ICON_SIZE = 42
export function GestureActionView({
children,
actions,
}: {
children: React.ReactNode
actions: GestureActions
}) {
if (
(actions.leftSecond && !actions.leftFirst) ||
(actions.rightSecond && !actions.rightFirst)
) {
throw new Error(
'You must provide the first action before the second action',
)
}
const [activeAction, setActiveAction] = React.useState<
'leftFirst' | 'leftSecond' | 'rightFirst' | 'rightSecond' | null
>(null)
const haptic = useHaptics()
const transX = useSharedValue(0)
const clampedTransX = useDerivedValue(() => {
const min = actions.leftFirst ? -MAX_WIDTH : 0
const max = actions.rightFirst ? MAX_WIDTH : 0
return clamp(transX.value, min, max)
})
const iconScale = useSharedValue(1)
const isActive = useSharedValue(false)
const hitFirst = useSharedValue(false)
const hitSecond = useSharedValue(false)
const runPopAnimation = () => {
'worklet'
iconScale.value = withSequence(
withTiming(1.2, {duration: 175}),
withTiming(1, {duration: 100}),
)
}
useAnimatedReaction(
() => transX,
() => {
if (transX.value === 0) {
runOnJS(setActiveAction)(null)
} else if (transX.value < 0) {
if (
actions.leftSecond &&
transX.value <= -actions.leftSecond.threshold
) {
if (activeAction !== 'leftSecond') {
runOnJS(setActiveAction)('leftSecond')
}
} else if (activeAction !== 'leftFirst') {
runOnJS(setActiveAction)('leftFirst')
}
} else if (transX.value > 0) {
if (
actions.rightSecond &&
transX.value > actions.rightSecond.threshold
) {
if (activeAction !== 'rightSecond') {
runOnJS(setActiveAction)('rightSecond')
}
} else if (activeAction !== 'rightFirst') {
runOnJS(setActiveAction)('rightFirst')
}
}
},
)
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10])
// Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
// reanimated doesn't offer great support for disabling y/x axes :/
.activeOffsetY([-200, 200])
.onStart(() => {
isActive.value = true
})
.onChange(e => {
transX.value = e.translationX
if (e.translationX < 0) {
// Left side
if (actions.leftSecond) {
if (
e.translationX <= -actions.leftSecond.threshold &&
!hitSecond.value
) {
runPopAnimation()
runOnJS(haptic)()
hitSecond.value = true
} else if (
hitSecond.value &&
e.translationX > -actions.leftSecond.threshold
) {
runPopAnimation()
hitSecond.value = false
}
}
if (!hitSecond.value && actions.leftFirst) {
if (
e.translationX <= -actions.leftFirst.threshold &&
!hitFirst.value
) {
runPopAnimation()
runOnJS(haptic)()
hitFirst.value = true
} else if (
hitFirst.value &&
e.translationX > -actions.leftFirst.threshold
) {
hitFirst.value = false
}
}
} else if (e.translationX > 0) {
// Right side
if (actions.rightSecond) {
if (
e.translationX >= actions.rightSecond.threshold &&
!hitSecond.value
) {
runPopAnimation()
runOnJS(haptic)()
hitSecond.value = true
} else if (
hitSecond.value &&
e.translationX < actions.rightSecond.threshold
) {
runPopAnimation()
hitSecond.value = false
}
}
if (!hitSecond.value && actions.rightFirst) {
if (
e.translationX >= actions.rightFirst.threshold &&
!hitFirst.value
) {
runPopAnimation()
runOnJS(haptic)()
hitFirst.value = true
} else if (
hitFirst.value &&
e.translationX < actions.rightFirst.threshold
) {
hitFirst.value = false
}
}
}
})
.onEnd(e => {
if (e.translationX < 0) {
if (hitSecond.value && actions.leftSecond) {
runOnJS(actions.leftSecond.action)()
} else if (hitFirst.value && actions.leftFirst) {
runOnJS(actions.leftFirst.action)()
}
} else if (e.translationX > 0) {
if (hitSecond.value && actions.rightSecond) {
runOnJS(actions.rightSecond.action)()
} else if (hitSecond.value && actions.rightFirst) {
runOnJS(actions.rightFirst.action)()
}
}
transX.value = withTiming(0, {duration: 200})
hitFirst.value = false
hitSecond.value = false
isActive.value = false
})
const composedGesture = Gesture.Simultaneous(panGesture)
const animatedSliderStyle = useAnimatedStyle(() => {
return {
transform: [{translateX: clampedTransX.value}],
}
})
const leftSideInterpolation = React.useMemo(() => {
return createInterpolation({
firstColor: actions.leftFirst?.color,
secondColor: actions.leftSecond?.color,
firstThreshold: actions.leftFirst?.threshold,
secondThreshold: actions.leftSecond?.threshold,
side: 'left',
})
}, [actions.leftFirst, actions.leftSecond])
const rightSideInterpolation = React.useMemo(() => {
return createInterpolation({
firstColor: actions.rightFirst?.color,
secondColor: actions.rightSecond?.color,
firstThreshold: actions.rightFirst?.threshold,
secondThreshold: actions.rightSecond?.threshold,
side: 'right',
})
}, [actions.rightFirst, actions.rightSecond])
const interpolation = React.useMemo<{
inputRange: number[]
outputRange: ColorValue[]
}>(() => {
if (!actions.leftFirst) {
return rightSideInterpolation!
} else if (!actions.rightFirst) {
return leftSideInterpolation!
} else {
return {
inputRange: [
...leftSideInterpolation.inputRange,
...rightSideInterpolation.inputRange,
],
outputRange: [
...leftSideInterpolation.outputRange,
...rightSideInterpolation.outputRange,
],
}
}
}, [
leftSideInterpolation,
rightSideInterpolation,
actions.leftFirst,
actions.rightFirst,
])
const animatedBackgroundStyle = useAnimatedStyle(() => {
return {
backgroundColor: interpolateColor(
clampedTransX.value,
interpolation.inputRange,
// @ts-expect-error - Weird type expected by reanimated, but this is okay
interpolation.outputRange,
),
}
})
const animatedIconStyle = useAnimatedStyle(() => {
const absTransX = Math.abs(clampedTransX.value)
return {
opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
transform: [{scale: iconScale.value}],
}
})
return (
<GestureDetector gesture={composedGesture}>
<View>
<Animated.View style={[absoluteFill, animatedBackgroundStyle]}>
<View
style={{
flex: 1,
marginHorizontal: 12,
justifyContent: 'center',
alignItems:
activeAction === 'leftFirst' || activeAction === 'leftSecond'
? 'flex-end'
: 'flex-start',
}}>
<Animated.View style={[animatedIconStyle]}>
{activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
<actions.leftFirst.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{
color: 'white',
}}
/>
) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
<actions.leftSecond.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{color: 'white'}}
/>
) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
<actions.rightFirst.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{color: 'white'}}
/>
) : activeAction === 'rightSecond' &&
actions.rightSecond?.icon ? (
<actions.rightSecond.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{color: 'white'}}
/>
) : null}
</Animated.View>
</View>
</Animated.View>
<Animated.View style={animatedSliderStyle}>{children}</Animated.View>
</View>
</GestureDetector>
)
}
function createInterpolation({
firstColor,
secondColor,
firstThreshold,
secondThreshold,
side,
}: {
firstColor?: ColorValue
secondColor?: ColorValue
firstThreshold?: number
secondThreshold?: number
side: 'left' | 'right'
}): {
inputRange: number[]
outputRange: ColorValue[]
} {
if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) {
throw new Error(
'You must provide a second color if you provide a second threshold',
)
}
if (!firstThreshold) {
return {
inputRange: [0],
outputRange: ['transparent'],
}
}
const offset = side === 'left' ? -15 : 15
if (side === 'left') {
firstThreshold = -firstThreshold
if (secondThreshold) {
secondThreshold = -secondThreshold
}
}
let res
if (secondThreshold) {
res = {
inputRange: [0, firstThreshold + offset, secondThreshold + offset],
outputRange: ['transparent', firstColor!, secondColor!],
}
} else {
res = {
inputRange: [0, firstThreshold + offset],
outputRange: ['transparent', firstColor!],
}
}
if (side === 'left') {
res = {
inputRange: res.inputRange.toReversed(),
outputRange: res.outputRange.toReversed(),
}
}
return res
}
@@ -0,0 +1,5 @@
import React from 'react'
export function SwipeActionItem({children}: {children: React.ReactNode}) {
return children
}
+191 -156
View File
@@ -19,6 +19,7 @@ import {ThreadPost} from '#/state/queries/post-thread'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
import {GestureActionView} from 'lib/custom-animations/GestureActionView'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
@@ -28,9 +29,12 @@ import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {usePostLikeMutationQueue} from 'state/queries/post'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Bubble_Stroke2_Corner2_Rounded} from '#/components/icons/Bubble'
import {Heart2_Filled_Stroke2_Corner0_Rounded} from '#/components/icons/Heart2'
import {AppModerationCause} from '#/components/Pills' import {AppModerationCause} from '#/components/Pills'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../components/moderation/ContentHider' import {ContentHider} from '../../../components/moderation/ContentHider'
@@ -181,12 +185,17 @@ let PostThreadItemLoaded = ({
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const t = useTheme()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = React.useState( const [limitLines, setLimitLines] = React.useState(
() => countLines(richText?.text) >= MAX_POST_LINES, () => countLines(richText?.text) >= MAX_POST_LINES,
) )
const [queueLike, queueUnlike] = usePostLikeMutationQueue(
post,
'PostThreadItem',
)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const rootUri = record.reply?.root?.uri || post.uri const rootUri = record.reply?.root?.uri || post.uri
const postHref = React.useMemo(() => { const postHref = React.useMemo(() => {
@@ -465,178 +474,204 @@ let PostThreadItemLoaded = ({
const isThreadedChildAdjacentBot = const isThreadedChildAdjacentBot =
isThreadedChild && nextPost?.ctx.depth === depth isThreadedChild && nextPost?.ctx.depth === depth
return ( return (
<PostOuterWrapper <GestureActionView
post={post} actions={{
depth={depth} leftFirst: {
showParentReplyLine={!!showParentReplyLine} color: '#ec4899',
treeView={treeView} action: () => {
hasPrecedingItem={hasPrecedingItem} if (!post.viewer?.like) {
hideTopBorder={hideTopBorder}> queueLike()
<PostHider } else {
testID={`postThreadItem-by-${post.author.handle}`} queueUnlike()
href={postHref} }
disabled={overrideBlur} },
style={[pal.view]} threshold: 85,
modui={moderation.ui('contentList')} icon: Heart2_Filled_Stroke2_Corner0_Rounded,
iconSize={isThreadedChild ? 26 : 38} },
iconStyles={ leftSecond: {
isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2} color: t.palette.primary_500,
} action: () => {
profile={post.author} onPressReply()
interpretFilterAsBlur> },
<View threshold: 170,
style={{ icon: Bubble_Stroke2_Corner2_Rounded,
flexDirection: 'row', },
gap: 10, }}>
paddingLeft: 8, <PostOuterWrapper
height: isThreadedChildAdjacentTop ? 8 : 16, post={post}
}}> depth={depth}
<View style={{width: 38}}> showParentReplyLine={!!showParentReplyLine}
{!isThreadedChild && showParentReplyLine && ( treeView={treeView}
<View hasPrecedingItem={hasPrecedingItem}
style={[ hideTopBorder={hideTopBorder}>
styles.replyLine, <PostHider
{ testID={`postThreadItem-by-${post.author.handle}`}
flexGrow: 1, href={postHref}
backgroundColor: pal.colors.replyLine, disabled={overrideBlur}
marginBottom: 4, style={[pal.view]}
}, modui={moderation.ui('contentList')}
]} iconSize={isThreadedChild ? 26 : 38}
/> iconStyles={
)} isThreadedChild
</View> ? {marginRight: 4}
</View> : {marginLeft: 2, marginRight: 2}
}
<View profile={post.author}
style={[ interpretFilterAsBlur>
styles.layout, <View
{ style={{
paddingBottom: flexDirection: 'row',
showChildReplyLine && !isThreadedChild gap: 10,
? 0 paddingLeft: 8,
: isThreadedChildAdjacentBot height: isThreadedChildAdjacentTop ? 8 : 16,
? 4 }}>
: 8, <View style={{width: 38}}>
}, {!isThreadedChild && showParentReplyLine && (
]}>
{/* If we are in threaded mode, the avatar is rendered in PostMeta */}
{!isThreadedChild && (
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={38}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
{showChildReplyLine && (
<View <View
style={[ style={[
styles.replyLine, styles.replyLine,
{ {
flexGrow: 1, flexGrow: 1,
backgroundColor: pal.colors.replyLine, backgroundColor: pal.colors.replyLine,
marginTop: 4, marginBottom: 4,
}, },
]} ]}
/> />
)} )}
</View> </View>
)} </View>
<View <View
style={ style={[
isThreadedChild styles.layout,
? styles.layoutContentThreaded {
: styles.layoutContent paddingBottom:
}> showChildReplyLine && !isThreadedChild
<PostMeta ? 0
author={post.author} : isThreadedChildAdjacentBot
moderation={moderation} ? 4
authorHasWarning={!!post.author.labels?.length} : 8,
timestamp={post.indexedAt} },
postHref={postHref} ]}>
showAvatar={isThreadedChild} {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
avatarModeration={moderation.ui('avatar')} {!isThreadedChild && (
avatarSize={28} <View style={styles.layoutAvi}>
displayNameType="md-bold" <PreviewableUserAvatar
displayNameStyle={isThreadedChild && s.ml2} size={38}
style={ profile={post.author}
isThreadedChild && { moderation={moderation.ui('avatar')}
alignItems: 'center', type={post.author.associated?.labeler ? 'labeler' : 'user'}
paddingBottom: isWeb ? 5 : 2,
}
}
/>
<LabelsOnMyPost post={post} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pt_2xs, a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View style={styles.postTextContainer}>
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
/>
</View>
) : undefined}
{limitLines ? (
<TextLink
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
/>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/> />
{showChildReplyLine && (
<View
style={[
styles.replyLine,
{
flexGrow: 1,
backgroundColor: pal.colors.replyLine,
marginTop: 4,
},
]}
/>
)}
</View> </View>
)} )}
<PostCtrls
post={post} <View
record={record} style={
richText={richText} isThreadedChild
onPressReply={onPressReply} ? styles.layoutContentThreaded
logContext="PostThreadItem" : styles.layoutContent
threadgateRecord={threadgateRecord} }>
/> <PostMeta
author={post.author}
moderation={moderation}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={postHref}
showAvatar={isThreadedChild}
avatarModeration={moderation.ui('avatar')}
avatarSize={28}
displayNameType="md-bold"
displayNameStyle={isThreadedChild && s.ml2}
style={
isThreadedChild && {
alignItems: 'center',
paddingBottom: isWeb ? 5 : 2,
}
}
/>
<LabelsOnMyPost post={post} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pt_2xs, a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View style={styles.postTextContainer}>
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
/>
</View>
) : undefined}
{limitLines ? (
<TextLink
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
/>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)}
<PostCtrls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View> </View>
</View> {hasMore ? (
{hasMore ? ( <Link
<Link style={[
style={[ styles.loadMore,
styles.loadMore, {
{ paddingLeft: treeView ? 8 : 70,
paddingLeft: treeView ? 8 : 70, paddingTop: 0,
paddingTop: 0, paddingBottom: treeView ? 4 : 12,
paddingBottom: treeView ? 4 : 12, },
}, ]}
]} href={postHref}
href={postHref} title={itemTitle}
title={itemTitle} noFeedback>
noFeedback> <Text type="sm-medium" style={pal.textLight}>
<Text type="sm-medium" style={pal.textLight}> <Trans>More</Trans>
<Trans>More</Trans> </Text>
</Text> <FontAwesomeIcon
<FontAwesomeIcon icon="angle-right"
icon="angle-right" color={pal.colors.textLight}
color={pal.colors.textLight} size={14}
size={14} />
/> </Link>
</Link> ) : undefined}
) : undefined} </PostHider>
</PostHider> </PostOuterWrapper>
</PostOuterWrapper> </GestureActionView>
) )
} }
} }