From 9889035a20e715bd9da426e8bcd13ed103e99e7e Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 1 Feb 2023 13:09:20 -0600 Subject: [PATCH] Reduce render cost of post controls and improve perceived responsiveness (#132) * Move post control animations into conditional render and increase perceived responsiveness * Remove log --- src/view/com/post-thread/PostThreadItem.tsx | 4 +- src/view/com/post/Post.tsx | 4 +- src/view/com/posts/FeedItem.tsx | 4 +- src/view/com/util/PostCtrls.tsx | 157 ++++++++++-------- .../com/util/anim/TriggerableAnimated.tsx | 73 ++++++++ src/view/shell/mobile/index.tsx | 2 + 6 files changed, 165 insertions(+), 79 deletions(-) create mode 100644 src/view/com/util/anim/TriggerableAnimated.tsx diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 92f7acc032..d3744e4251 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -70,12 +70,12 @@ export const PostThreadItem = observer(function PostThreadItem({ }) } const onPressToggleRepost = () => { - item + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index d00cc83c27..2f2d2bef63 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -105,12 +105,12 @@ export const Post = observer(function Post({ }) } const onPressToggleRepost = () => { - item + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 584fa09738..21eda1d8eb 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -61,12 +61,12 @@ export const FeedItem = observer(function ({ }) } const onPressToggleRepost = () => { - item + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 3db7370d92..dfa90fb59b 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -9,6 +9,10 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import ReactNativeHapticFeedback from 'react-native-haptic-feedback' +import { + TriggerableAnimated, + TriggerableAnimatedRef, +} from './anim/TriggerableAnimated' import {Text} from './text/Text' import {PostDropdownBtn} from './forms/DropdownButton' import { @@ -19,7 +23,6 @@ import { } from '../../lib/icons' import {s, colors} from '../../lib/styles' import {useTheme} from '../../lib/ThemeContext' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' interface PostCtrlsOpts { itemHref: string @@ -33,14 +36,47 @@ interface PostCtrlsOpts { isReposted: boolean isUpvoted: boolean onPressReply: () => void - onPressToggleRepost: () => void - onPressToggleUpvote: () => void + onPressToggleRepost: () => Promise + onPressToggleUpvote: () => Promise onCopyPostText: () => void onDeletePost: () => void } const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} +function ctrlAnimStart(interp: Animated.Value) { + return Animated.sequence([ + Animated.timing(interp, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }), + Animated.delay(50), + Animated.timing(interp, { + toValue: 0, + duration: 20, + useNativeDriver: true, + }), + ]) +} + +function ctrlAnimStyle(interp: Animated.Value) { + return { + transform: [ + { + scale: interp.interpolate({ + inputRange: [0, 1.0], + outputRange: [1.0, 4.0], + }), + }, + ], + opacity: interp.interpolate({ + inputRange: [0, 1.0], + outputRange: [1.0, 0.0], + }), + } +} + export function PostCtrls(opts: PostCtrlsOpts) { const theme = useTheme() const defaultCtrlColor = React.useMemo( @@ -49,75 +85,47 @@ export function PostCtrls(opts: PostCtrlsOpts) { }), [theme], ) - const interp1 = useAnimatedValue(0) - const interp2 = useAnimatedValue(0) - - const anim1Style = { - transform: [ - { - scale: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 4.0], - }), - }, - ], - opacity: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 0.0], - }), - } - const anim2Style = { - transform: [ - { - scale: interp2.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 4.0], - }), - }, - ], - opacity: interp2.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 0.0], - }), - } - + const [repostMod, setRepostMod] = React.useState(0) + const [likeMod, setLikeMod] = React.useState(0) + const repostRef = React.useRef(null) + const likeRef = React.useRef(null) const onPressToggleRepostWrapper = () => { if (!opts.isReposted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp1, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp1, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setRepostMod(1) + repostRef.current?.trigger( + {start: ctrlAnimStart, style: ctrlAnimStyle}, + async () => { + await opts.onPressToggleRepost().catch(_e => undefined) + setRepostMod(0) + }, + ) + } else { + setRepostMod(-1) + opts + .onPressToggleRepost() + .catch(_e => undefined) + .then(() => setRepostMod(0)) } - opts.onPressToggleRepost() } const onPressToggleUpvoteWrapper = () => { if (!opts.isUpvoted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp2, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp2, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setLikeMod(1) + likeRef.current?.trigger( + {start: ctrlAnimStart, style: ctrlAnimStyle}, + async () => { + await opts.onPressToggleUpvote().catch(_e => undefined) + setLikeMod(0) + }, + ) + } else { + setLikeMod(-1) + opts + .onPressToggleUpvote() + .catch(_e => undefined) + .then(() => setLikeMod(0)) } - opts.onPressToggleUpvote() } return ( @@ -144,23 +152,26 @@ export function PostCtrls(opts: PostCtrlsOpts) { hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} style={styles.ctrl}> - + 0 + ? styles.ctrlIconReposted + : defaultCtrlColor } strokeWidth={2.4} size={opts.big ? 24 : 20} /> - + + {typeof opts.repostCount !== 'undefined' ? ( 0 ? [s.bold, s.green3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.repostCount} + {opts.repostCount + repostMod} ) : undefined} @@ -170,8 +181,8 @@ export function PostCtrls(opts: PostCtrlsOpts) { style={styles.ctrl} hitSlop={HITSLOP} onPress={onPressToggleUpvoteWrapper}> - - {opts.isUpvoted ? ( + + {opts.isUpvoted || likeMod > 0 ? ( )} - + {typeof opts.upvoteCount !== 'undefined' ? ( 0 ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.upvoteCount} + {opts.upvoteCount + likeMod} ) : undefined} diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx new file mode 100644 index 0000000000..279d8f99fb --- /dev/null +++ b/src/view/com/util/anim/TriggerableAnimated.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import {Animated, StyleProp, View, ViewStyle} from 'react-native' +import {useAnimatedValue} from '../../../lib/hooks/useAnimatedValue' + +type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation +type FinishCb = () => void + +interface TriggeredAnimation { + start: CreateAnimFn + style: ( + interp: Animated.Value, + ) => Animated.WithAnimatedValue> +} + +export interface TriggerableAnimatedRef { + trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void +} + +type TriggerableAnimatedProps = React.PropsWithChildren<{}> + +type PropsInner = TriggerableAnimatedProps & { + anim: TriggeredAnimation + onFinish: () => void +} + +export const TriggerableAnimated = React.forwardRef< + TriggerableAnimatedRef, + TriggerableAnimatedProps +>(({children, ...props}, ref) => { + const [anim, setAnim] = React.useState( + undefined, + ) + const [finishCb, setFinishCb] = React.useState( + undefined, + ) + React.useImperativeHandle(ref, () => ({ + trigger(v: TriggeredAnimation, cb?: FinishCb) { + setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate + setAnim(v) + }, + })) + const onFinish = () => { + finishCb?.() + setAnim(undefined) + setFinishCb(undefined) + } + return ( + + {anim ? ( + + {children} + + ) : ( + children + )} + + ) +}) + +function AnimatingView({ + anim, + onFinish, + children, +}: React.PropsWithChildren) { + const interp = useAnimatedValue(0) + React.useEffect(() => { + anim?.start(interp).start(() => { + onFinish() + }) + }) + const animStyle = anim?.style(interp) + return {children} +} diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index 83cb7652ec..95fc826072 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -175,12 +175,14 @@ export const MobileShell: React.FC = observer(() => { toValue: 1, duration: 100, useNativeDriver: true, + isInteraction: false, }).start() } else { Animated.timing(minimalShellInterp, { toValue: 0, duration: 100, useNativeDriver: true, + isInteraction: false, }).start() } }, [minimalShellInterp, store.shell.minimalShellMode])