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 = () => {
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user