Skip to content

Commit

Permalink
Reduce render cost of post controls and improve perceived responsiven…
Browse files Browse the repository at this point in the history
…ess (#132)

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

* Remove log
  • Loading branch information
pfrazee authored Feb 1, 2023
1 parent ad511bc commit 9889035
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 73 deletions.
4 changes: 2 additions & 2 deletions src/view/com/post-thread/PostThreadItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
4 changes: 2 additions & 2 deletions src/view/com/post/Post.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
4 changes: 2 additions & 2 deletions src/view/com/posts/FeedItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
145 changes: 78 additions & 67 deletions src/view/com/util/PostCtrls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -33,91 +36,96 @@ 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}

export function PostCtrls(opts: PostCtrlsOpts) {
const theme = useTheme()
const defaultCtrlColor = React.useMemo(
() => ({
color: theme.palette.default.postCtrl,
function ctrlAnimStart(interp: Animated.Value) {
return Animated.sequence([
Animated.timing(interp, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
[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],
Animated.delay(50),
Animated.timing(interp, {
toValue: 0,
duration: 20,
useNativeDriver: true,
}),
}
const anim2Style = {
])
}

function ctrlAnimStyle(interp: Animated.Value) {
return {
transform: [
{
scale: interp2.interpolate({
scale: interp.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 4.0],
}),
},
],
opacity: interp2.interpolate({
opacity: interp.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 0.0],
}),
}
}

export function PostCtrls(opts: PostCtrlsOpts) {
const theme = useTheme()
const defaultCtrlColor = React.useMemo(
() => ({
color: theme.palette.default.postCtrl,
}),
[theme],
)
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 (
Expand All @@ -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>
Expand All @@ -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}
Expand All @@ -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>
Expand Down
73 changes: 73 additions & 0 deletions src/view/com/util/anim/TriggerableAnimated.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions src/view/shell/mobile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down

0 comments on commit 9889035

Please sign in to comment.