Reduce render cost of post controls and improve perceived responsiveness (#132)

* Move post control animations into conditional render and increase perceived responsiveness

* Remove log
This commit is contained in:
Paul Frazee
2023-02-01 13:09:20 -06:00
committed by GitHub
parent ad511bc574
commit 9889035a20
6 changed files with 165 additions and 79 deletions
+2 -2
View File
@@ -70,12 +70,12 @@ export const PostThreadItem = observer(function PostThreadItem({
}) })
} }
const onPressToggleRepost = () => { const onPressToggleRepost = () => {
item return item
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
} }
const onPressToggleUpvote = () => { const onPressToggleUpvote = () => {
item return item
.toggleUpvote() .toggleUpvote()
.catch(e => store.log.error('Failed to toggle upvote', e)) .catch(e => store.log.error('Failed to toggle upvote', e))
} }
+2 -2
View File
@@ -105,12 +105,12 @@ export const Post = observer(function Post({
}) })
} }
const onPressToggleRepost = () => { const onPressToggleRepost = () => {
item return item
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
} }
const onPressToggleUpvote = () => { const onPressToggleUpvote = () => {
item return item
.toggleUpvote() .toggleUpvote()
.catch(e => store.log.error('Failed to toggle upvote', e)) .catch(e => store.log.error('Failed to toggle upvote', e))
} }
+2 -2
View File
@@ -61,12 +61,12 @@ export const FeedItem = observer(function ({
}) })
} }
const onPressToggleRepost = () => { const onPressToggleRepost = () => {
item return item
.toggleRepost() .toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e)) .catch(e => store.log.error('Failed to toggle repost', e))
} }
const onPressToggleUpvote = () => { const onPressToggleUpvote = () => {
item return item
.toggleUpvote() .toggleUpvote()
.catch(e => store.log.error('Failed to toggle upvote', e)) .catch(e => store.log.error('Failed to toggle upvote', e))
} }
+84 -73
View File
@@ -9,6 +9,10 @@ import {
} from 'react-native' } from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import ReactNativeHapticFeedback from 'react-native-haptic-feedback' import ReactNativeHapticFeedback from 'react-native-haptic-feedback'
import {
TriggerableAnimated,
TriggerableAnimatedRef,
} from './anim/TriggerableAnimated'
import {Text} from './text/Text' import {Text} from './text/Text'
import {PostDropdownBtn} from './forms/DropdownButton' import {PostDropdownBtn} from './forms/DropdownButton'
import { import {
@@ -19,7 +23,6 @@ import {
} from '../../lib/icons' } from '../../lib/icons'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {useTheme} from '../../lib/ThemeContext' import {useTheme} from '../../lib/ThemeContext'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
interface PostCtrlsOpts { interface PostCtrlsOpts {
itemHref: string itemHref: string
@@ -33,14 +36,47 @@ interface PostCtrlsOpts {
isReposted: boolean isReposted: boolean
isUpvoted: boolean isUpvoted: boolean
onPressReply: () => void onPressReply: () => void
onPressToggleRepost: () => void onPressToggleRepost: () => Promise<void>
onPressToggleUpvote: () => void onPressToggleUpvote: () => Promise<void>
onCopyPostText: () => void onCopyPostText: () => void
onDeletePost: () => void onDeletePost: () => void
} }
const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} 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) { export function PostCtrls(opts: PostCtrlsOpts) {
const theme = useTheme() const theme = useTheme()
const defaultCtrlColor = React.useMemo( const defaultCtrlColor = React.useMemo(
@@ -49,75 +85,47 @@ export function PostCtrls(opts: PostCtrlsOpts) {
}), }),
[theme], [theme],
) )
const interp1 = useAnimatedValue(0) const [repostMod, setRepostMod] = React.useState<number>(0)
const interp2 = useAnimatedValue(0) const [likeMod, setLikeMod] = React.useState<number>(0)
const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
const anim1Style = { const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
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 onPressToggleRepostWrapper = () => { const onPressToggleRepostWrapper = () => {
if (!opts.isReposted) { if (!opts.isReposted) {
ReactNativeHapticFeedback.trigger('impactMedium') ReactNativeHapticFeedback.trigger('impactMedium')
Animated.sequence([ setRepostMod(1)
Animated.timing(interp1, { repostRef.current?.trigger(
toValue: 1, {start: ctrlAnimStart, style: ctrlAnimStyle},
duration: 400, async () => {
useNativeDriver: true, await opts.onPressToggleRepost().catch(_e => undefined)
}), setRepostMod(0)
Animated.delay(100), },
Animated.timing(interp1, { )
toValue: 0, } else {
duration: 20, setRepostMod(-1)
useNativeDriver: true, opts
}), .onPressToggleRepost()
]).start() .catch(_e => undefined)
.then(() => setRepostMod(0))
} }
opts.onPressToggleRepost()
} }
const onPressToggleUpvoteWrapper = () => { const onPressToggleUpvoteWrapper = () => {
if (!opts.isUpvoted) { if (!opts.isUpvoted) {
ReactNativeHapticFeedback.trigger('impactMedium') ReactNativeHapticFeedback.trigger('impactMedium')
Animated.sequence([ setLikeMod(1)
Animated.timing(interp2, { likeRef.current?.trigger(
toValue: 1, {start: ctrlAnimStart, style: ctrlAnimStyle},
duration: 400, async () => {
useNativeDriver: true, await opts.onPressToggleUpvote().catch(_e => undefined)
}), setLikeMod(0)
Animated.delay(100), },
Animated.timing(interp2, { )
toValue: 0, } else {
duration: 20, setLikeMod(-1)
useNativeDriver: true, opts
}), .onPressToggleUpvote()
]).start() .catch(_e => undefined)
.then(() => setLikeMod(0))
} }
opts.onPressToggleUpvote()
} }
return ( return (
@@ -144,23 +152,26 @@ export function PostCtrls(opts: PostCtrlsOpts) {
hitSlop={HITSLOP} hitSlop={HITSLOP}
onPress={onPressToggleRepostWrapper} onPress={onPressToggleRepostWrapper}
style={styles.ctrl}> style={styles.ctrl}>
<Animated.View style={anim1Style}> <TriggerableAnimated ref={repostRef}>
<RepostIcon <RepostIcon
style={ style={
opts.isReposted ? styles.ctrlIconReposted : defaultCtrlColor opts.isReposted || repostMod > 0
? styles.ctrlIconReposted
: defaultCtrlColor
} }
strokeWidth={2.4} strokeWidth={2.4}
size={opts.big ? 24 : 20} size={opts.big ? 24 : 20}
/> />
</Animated.View> </TriggerableAnimated>
{typeof opts.repostCount !== 'undefined' ? ( {typeof opts.repostCount !== 'undefined' ? (
<Text <Text
style={ style={
opts.isReposted opts.isReposted || repostMod > 0
? [s.bold, s.green3, s.f15, s.ml5] ? [s.bold, s.green3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5]
}> }>
{opts.repostCount} {opts.repostCount + repostMod}
</Text> </Text>
) : undefined} ) : undefined}
</TouchableOpacity> </TouchableOpacity>
@@ -170,8 +181,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
style={styles.ctrl} style={styles.ctrl}
hitSlop={HITSLOP} hitSlop={HITSLOP}
onPress={onPressToggleUpvoteWrapper}> onPress={onPressToggleUpvoteWrapper}>
<Animated.View style={anim2Style}> <TriggerableAnimated ref={likeRef}>
{opts.isUpvoted ? ( {opts.isUpvoted || likeMod > 0 ? (
<HeartIconSolid <HeartIconSolid
style={[styles.ctrlIconUpvoted]} style={[styles.ctrlIconUpvoted]}
size={opts.big ? 22 : 16} size={opts.big ? 22 : 16}
@@ -183,15 +194,15 @@ export function PostCtrls(opts: PostCtrlsOpts) {
size={opts.big ? 20 : 16} size={opts.big ? 20 : 16}
/> />
)} )}
</Animated.View> </TriggerableAnimated>
{typeof opts.upvoteCount !== 'undefined' ? ( {typeof opts.upvoteCount !== 'undefined' ? (
<Text <Text
style={ style={
opts.isUpvoted opts.isUpvoted || likeMod > 0
? [s.bold, s.red3, s.f15, s.ml5] ? [s.bold, s.red3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5]
}> }>
{opts.upvoteCount} {opts.upvoteCount + likeMod}
</Text> </Text>
) : undefined} ) : undefined}
</TouchableOpacity> </TouchableOpacity>
@@ -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<StyleProp<ViewStyle>>
}
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<TriggeredAnimation | undefined>(
undefined,
)
const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>(
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 (
<View key="triggerable">
{anim ? (
<AnimatingView anim={anim} onFinish={onFinish} {...props}>
{children}
</AnimatingView>
) : (
children
)}
</View>
)
})
function AnimatingView({
anim,
onFinish,
children,
}: React.PropsWithChildren<PropsInner>) {
const interp = useAnimatedValue(0)
React.useEffect(() => {
anim?.start(interp).start(() => {
onFinish()
})
})
const animStyle = anim?.style(interp)
return <Animated.View style={animStyle}>{children}</Animated.View>
}
+2
View File
@@ -175,12 +175,14 @@ export const MobileShell: React.FC = observer(() => {
toValue: 1, toValue: 1,
duration: 100, duration: 100,
useNativeDriver: true, useNativeDriver: true,
isInteraction: false,
}).start() }).start()
} else { } else {
Animated.timing(minimalShellInterp, { Animated.timing(minimalShellInterp, {
toValue: 0, toValue: 0,
duration: 100, duration: 100,
useNativeDriver: true, useNativeDriver: true,
isInteraction: false,
}).start() }).start()
} }
}, [minimalShellInterp, store.shell.minimalShellMode]) }, [minimalShellInterp, store.shell.minimalShellMode])