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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
onPressToggleUpvote: () => Promise<void>
|
||||
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<number>(0)
|
||||
const [likeMod, setLikeMod] = React.useState<number>(0)
|
||||
const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
||||
const likeRef = React.useRef<TriggerableAnimatedRef | null>(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}>
|
||||
<Animated.View style={anim1Style}>
|
||||
<TriggerableAnimated ref={repostRef}>
|
||||
<RepostIcon
|
||||
style={
|
||||
opts.isReposted ? styles.ctrlIconReposted : defaultCtrlColor
|
||||
opts.isReposted || repostMod > 0
|
||||
? styles.ctrlIconReposted
|
||||
: defaultCtrlColor
|
||||
}
|
||||
strokeWidth={2.4}
|
||||
size={opts.big ? 24 : 20}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TriggerableAnimated>
|
||||
|
||||
{typeof opts.repostCount !== 'undefined' ? (
|
||||
<Text
|
||||
style={
|
||||
opts.isReposted
|
||||
opts.isReposted || repostMod > 0
|
||||
? [s.bold, s.green3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.repostCount}
|
||||
{opts.repostCount + repostMod}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
@@ -170,8 +181,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
||||
style={styles.ctrl}
|
||||
hitSlop={HITSLOP}
|
||||
onPress={onPressToggleUpvoteWrapper}>
|
||||
<Animated.View style={anim2Style}>
|
||||
{opts.isUpvoted ? (
|
||||
<TriggerableAnimated ref={likeRef}>
|
||||
{opts.isUpvoted || likeMod > 0 ? (
|
||||
<HeartIconSolid
|
||||
style={[styles.ctrlIconUpvoted]}
|
||||
size={opts.big ? 22 : 16}
|
||||
@@ -183,15 +194,15 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
||||
size={opts.big ? 20 : 16}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</TriggerableAnimated>
|
||||
{typeof opts.upvoteCount !== 'undefined' ? (
|
||||
<Text
|
||||
style={
|
||||
opts.isUpvoted
|
||||
opts.isUpvoted || likeMod > 0
|
||||
? [s.bold, s.red3, s.f15, s.ml5]
|
||||
: [defaultCtrlColor, s.f15, s.ml5]
|
||||
}>
|
||||
{opts.upvoteCount}
|
||||
{opts.upvoteCount + likeMod}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</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>
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user