diff --git a/src/components/Interactions/Swipe/Swipe.test.tsx b/src/components/Interactions/Swipe/Swipe.test.tsx index a343189a..528801f9 100644 --- a/src/components/Interactions/Swipe/Swipe.test.tsx +++ b/src/components/Interactions/Swipe/Swipe.test.tsx @@ -15,10 +15,6 @@ jest.mock('react-native-reanimated', () => ({ __esModule: true, useSharedValue: function (i: unknown) { return { value: i }; }, useAnimatedStyle: function (fn: () => unknown) { return fn(); }, - useAnimatedReaction: function (_p: () => unknown, r: (c: unknown, p: unknown) => void) { - (globalThis as any).__react = r; - r(_p(), undefined); - }, runOnJS: function (fn: (...args: unknown[]) => unknown) { return fn; }, withTiming: function (_t: number) { return _t; }, default: { View: function () { return null; } }, @@ -56,7 +52,6 @@ jest.mock('react-native-gesture-handler', () => ({ beforeEach(() => { (globalThis as any).__ph = {}; - (globalThis as any).__react = undefined; jest.useRealTimers(); }); @@ -78,11 +73,6 @@ const renderSwipe = ({ onRender }: { onRender?: (id: string) => void; } = {}) => return { dispatch, store }; }; -const triggerReaction = (totalOffset: number, prevTotalOffset: number) => { - const react = (globalThis as any).__react; - if (react) react(totalOffset, prevTotalOffset); -}; - describe('SwipeVertical', () => { it('renders and captures gesture callbacks', () => { renderSwipe(); @@ -98,7 +88,10 @@ describe('SwipeVertical', () => { ph.onBegin(); ph.onUpdate({ translationY: -100 }); - triggerReaction(100, 0); + expect(dispatch).not.toHaveBeenCalledWith( + playerRoundScoreIncrement('player-1', 0, 2) + ); + act(() => { ph.onEnd({ translationY: -100 }); ph.onFinalize(); @@ -115,7 +108,6 @@ describe('SwipeVertical', () => { ph.onBegin(); ph.onUpdate({ translationY: 100 }); - triggerReaction(-100, 0); act(() => { ph.onEnd({ translationY: 100 }); ph.onFinalize(); @@ -132,7 +124,6 @@ describe('SwipeVertical', () => { ph.onBegin(); ph.onUpdate({ translationY: -250 }); - triggerReaction(250, 0); act(() => { ph.onEnd({ translationY: -250 }); ph.onFinalize(); @@ -152,7 +143,6 @@ describe('SwipeVertical', () => { act(() => { jest.advanceTimersByTime(401); }); ph.onUpdate({ translationY: -100 }); - triggerReaction(100, 0); act(() => { ph.onEnd({ translationY: -100 }); ph.onFinalize(); @@ -170,12 +160,10 @@ describe('SwipeVertical', () => { ph.onBegin(); ph.onUpdate({ translationY: -2 }); - triggerReaction(2, 0); act(() => { jest.advanceTimersByTime(401); }); ph.onUpdate({ translationY: -100 }); - triggerReaction(100, 2); act(() => { ph.onEnd({ translationY: -100 }); ph.onFinalize(); @@ -186,6 +174,30 @@ describe('SwipeVertical', () => { ); }); + it('flushes only once when onEnd and onFinalize both run', () => { + const { dispatch } = renderSwipe(); + const ph = (globalThis as any).__ph; + const expectedAction = playerRoundScoreIncrement('player-1', 0, 2); + + ph.onBegin(); + ph.onUpdate({ translationY: -100 }); + + act(() => { + ph.onEnd({ translationY: -100 }); + ph.onFinalize(); + }); + + expect(dispatch.mock.calls.filter(([action]) => { + const dispatchedAction = action as typeof expectedAction; + return ( + dispatchedAction.type === expectedAction.type && + dispatchedAction.payload === expectedAction.payload && + dispatchedAction.meta.round === expectedAction.meta.round && + dispatchedAction.meta.multiplier === expectedAction.meta.multiplier + ); + })).toHaveLength(1); + }); + it('does not re-render solely because the current round changes', () => { const onRender = jest.fn(); const { store } = renderSwipe({ onRender }); @@ -211,7 +223,6 @@ describe('SwipeVertical', () => { ph.onBegin(); ph.onUpdate({ translationY: -100 }); - triggerReaction(100, 0); act(() => { ph.onEnd({ translationY: -100 }); ph.onFinalize(); diff --git a/src/components/Interactions/Swipe/Swipe.tsx b/src/components/Interactions/Swipe/Swipe.tsx index 22e1cecd..feead1a7 100644 --- a/src/components/Interactions/Swipe/Swipe.tsx +++ b/src/components/Interactions/Swipe/Swipe.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as Haptics from 'expo-haptics'; import { Animated, StyleSheet, Text, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import ReAnimated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import ReAnimated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { selectGameById } from '../../../../redux/GamesSlice'; import { useAppDispatch, useAppSelector, useAppStore } from '../../../../redux/hooks'; @@ -11,6 +11,7 @@ import { playerRoundScoreIncrement } from '../../../../redux/PlayersSlice'; import { setLastUsedInteractionType } from '../../../../redux/SettingsSlice'; import { logEvent } from '../../../Analytics'; import { useMenuOpen } from '../../MenuOpenContext'; +import { OptimisticScoreContext } from '../../PlayerTiles/AdditionTile/OptimisticScoreContext'; import { InteractionType } from '../InteractionType'; interface HalfTapProps { @@ -53,6 +54,19 @@ const SwipeVertical: React.FC = ({ const addendOne = useAppSelector(state => state.settings.addendOne); const addendTwo = useAppSelector(state => state.settings.addendTwo); + const svAddendOne = useSharedValue(addendOne); + const svAddendTwo = useSharedValue(addendTwo); + const svRoundScore = useSharedValue(0); + const svRoundTotalScore = useSharedValue(0); + const optimisticScores = useMemo(() => ({ + currentRoundScore: svRoundScore, + currentRoundTotalScore: svRoundTotalScore, + }), [svRoundScore, svRoundTotalScore]); + + useEffect(() => { + svAddendOne.value = addendOne; + svAddendTwo.value = addendTwo; + }, [addendOne, addendTwo]); //#endregion @@ -141,16 +155,32 @@ const SwipeVertical: React.FC = ({ //#region Gesture handling - const totalOffset = useSharedValue(0); const panY = useSharedValue(0); + const svStartRoundScore = useSharedValue(0); + const svStartRoundTotalScore = useSharedValue(0); + const svPendingDelta = useSharedValue(0); + const svLastNotches = useSharedValue(0); + const svDidFlush = useSharedValue(true); const { menuOpen } = useMenuOpen(); - const getCurrentRoundIndex = useCallback(() => { + const getCurrentRoundStats = useCallback(() => { const state = store.getState(); const currentGameId = state.settings.currentGameId; - return currentGameId ? selectGameById(state, currentGameId)?.roundCurrent ?? 0 : 0; - }, [store]); + const currentGame = currentGameId ? selectGameById(state, currentGameId) : undefined; + const currentRoundIndex = currentGame?.roundCurrent ?? 0; + const scores: number[] = state.players.entities[playerId]?.scores ?? []; + const currentRoundScore = scores[currentRoundIndex] ?? 0; + const previousTotal = scores.reduce( + (sum, s, i) => (i < currentRoundIndex ? sum + (s || 0) : sum), 0 + ); + + return { + currentRoundIndex, + currentRoundScore, + currentRoundTotalScore: previousTotal + currentRoundScore, + }; + }, [playerId, store]); const endGesture = useCallback((translationY: number) => { if (menuOpen) return; @@ -158,36 +188,78 @@ const SwipeVertical: React.FC = ({ player_index: index, game_id: currentGameId, addend: secondaryHold ? addendTwo : addendOne, - round: getCurrentRoundIndex(), + round: getCurrentRoundStats().currentRoundIndex, type: translationY > 0 ? 'decrement' : 'increment', power_hold: secondaryHold, notches: -Math.round((translationY || 0) / notchSize), interaction: 'swipe-vertical', }); secondaryHoldStop(); - }, [index, currentGameId, secondaryHold, addendOne, addendTwo, getCurrentRoundIndex, menuOpen]); + }, [index, currentGameId, secondaryHold, addendOne, addendTwo, getCurrentRoundStats, menuOpen]); + + const flushPendingChange = useCallback((delta: number) => { + if (delta === 0) return; + if (menuOpen) return; + + dispatch(playerRoundScoreIncrement(playerId, getCurrentRoundStats().currentRoundIndex, delta)); + dispatch(setLastUsedInteractionType(InteractionType.SwipeVertical)); + }, [dispatch, playerId, getCurrentRoundStats, menuOpen]); + + const bumpFeedback = useCallback((secondary: boolean) => { + if (secondary) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } else { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + }, []); const panGesture = Gesture.Pan() .enabled(!currentGameLocked && !menuOpen) .minDistance(0) .onBegin(() => { + svStartRoundScore.value = svRoundScore.value; + svStartRoundTotalScore.value = svRoundTotalScore.value; + svPendingDelta.value = 0; + svLastNotches.value = 0; + svDidFlush.value = false; runOnJS(secondaryHoldStart)(); }) .onUpdate((event) => { const y = event.translationY; panY.value = y; - totalOffset.value = -y; if (isSecondaryHoldActive.value == false && Math.abs(y) > 1) { runOnJS(secondaryHoldStop)(); } + + const notches = Math.round((-y || 0) / notchSize); + if (notches === svLastNotches.value) return; + + svLastNotches.value = notches; + const secondaryActive = isSecondaryHoldActive.value; + const scoreDelta = notches * (secondaryActive ? svAddendTwo.value : svAddendOne.value); + svPendingDelta.value = scoreDelta; + svRoundScore.value = svStartRoundScore.value + scoreDelta; + svRoundTotalScore.value = svStartRoundTotalScore.value + scoreDelta; + runOnJS(bumpFeedback)(secondaryActive); }) .onEnd((event) => { - totalOffset.value = null; + if (!svDidFlush.value) { + svDidFlush.value = true; + if (svPendingDelta.value !== 0) { + runOnJS(flushPendingChange)(svPendingDelta.value); + } + } panY.value = withTiming(0, { duration: 200 }); runOnJS(endGesture)(event.translationY); }) .onFinalize(() => { + if (!svDidFlush.value) { + svDidFlush.value = true; + if (svPendingDelta.value !== 0) { + runOnJS(flushPendingChange)(svPendingDelta.value); + } + } runOnJS(secondaryHoldStop)(); }); @@ -197,44 +269,8 @@ const SwipeVertical: React.FC = ({ //#endregion - //#region Helpers - - const scoreChangeHandler = (value: number) => { - if (Math.abs(value) == 0) return; - if (menuOpen) return; - - const scoreDelta = value * (secondaryHold ? addendTwo : addendOne); - const currentRoundIndex = getCurrentRoundIndex(); - - if (secondaryHold) { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - } else { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - - dispatch(playerRoundScoreIncrement(playerId, currentRoundIndex, scoreDelta)); - dispatch(setLastUsedInteractionType(InteractionType.SwipeVertical)); - }; - - useAnimatedReaction( - () => { - return totalOffset.value; - }, - (currentValue, previousValue) => { - if (currentValue === null) return; - - const c = Math.round((currentValue || 0) / notchSize); - const p = Math.round((previousValue || 0) / notchSize); - if (c - p !== 0) { - runOnJS(scoreChangeHandler)(c - p); - } - } - ); - - //#endregion - return ( - <> + = ({ - + ); }; diff --git a/src/components/PlayerTiles/AdditionTile/AdditionTile.tsx b/src/components/PlayerTiles/AdditionTile/AdditionTile.tsx index e083ee9a..1fa8aa28 100644 --- a/src/components/PlayerTiles/AdditionTile/AdditionTile.tsx +++ b/src/components/PlayerTiles/AdditionTile/AdditionTile.tsx @@ -8,6 +8,7 @@ import { selectGameById } from '../../../../redux/GamesSlice'; import { useAppSelector } from '../../../../redux/hooks'; import { calculateFontSize } from './Helpers'; +import { OptimisticScoreContext } from './OptimisticScoreContext'; import ScoreAfter from './ScoreAfter'; import ScoreBefore from './ScoreBefore'; import ScoreRound from './ScoreRound'; @@ -60,6 +61,15 @@ const AdditionTile: React.FunctionComponent = ({ }; }, shallowEqual); + const optimisticScores = React.useContext(OptimisticScoreContext); + + React.useLayoutEffect(() => { + if (optimisticScores == null) return; + + optimisticScores.currentRoundScore.value = currentRoundScore; + optimisticScores.currentRoundTotalScore.value = currentRoundTotalScore; + }, [currentRoundScore, currentRoundTotalScore, optimisticScores]); + if (!hasCurrentGame) return null; if (typeof playerName == 'undefined') return null; @@ -99,13 +109,18 @@ const AdditionTile: React.FunctionComponent = ({ + fontColor={fontColor} + optimisticCurrentRoundScore={optimisticScores?.currentRoundScore} + optimisticCurrentRoundTotalScore={optimisticScores?.currentRoundTotalScore} /> + fontColor={fontColor} + optimisticCurrentRoundScore={optimisticScores?.currentRoundScore} /> + fontColor={fontColor} + optimisticCurrentRoundScore={optimisticScores?.currentRoundScore} + optimisticCurrentRoundTotalScore={optimisticScores?.currentRoundTotalScore} /> ); }; diff --git a/src/components/PlayerTiles/AdditionTile/AnimatedScoreText.tsx b/src/components/PlayerTiles/AdditionTile/AnimatedScoreText.tsx new file mode 100644 index 00000000..43aec796 --- /dev/null +++ b/src/components/PlayerTiles/AdditionTile/AnimatedScoreText.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { StyleProp, TextInput, TextInputProps, TextStyle } from 'react-native'; +import Animated, { AnimatedStyle, SharedValue, useAnimatedProps } from 'react-native-reanimated'; + +type AnimatedTextInputProps = TextInputProps & { text?: string }; + +const AnimatedTextInput = Animated.createAnimatedComponent( + TextInput as React.ComponentType +); + +interface Props extends Omit { + style?: StyleProp>; + text: SharedValue; +} + +const AnimatedScoreText: React.FC = ({ text, ...props }) => { + const animatedProps = useAnimatedProps(() => ({ + text: text.value, + })); + + return ( + + ); +}; + +export default AnimatedScoreText; diff --git a/src/components/PlayerTiles/AdditionTile/OptimisticScoreContext.ts b/src/components/PlayerTiles/AdditionTile/OptimisticScoreContext.ts new file mode 100644 index 00000000..fb8edaa6 --- /dev/null +++ b/src/components/PlayerTiles/AdditionTile/OptimisticScoreContext.ts @@ -0,0 +1,10 @@ +import React from 'react'; + +import { SharedValue } from 'react-native-reanimated'; + +export interface OptimisticScoreValues { + currentRoundScore: SharedValue; + currentRoundTotalScore: SharedValue; +} + +export const OptimisticScoreContext = React.createContext(null); diff --git a/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx b/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx index 338d3747..b14f97b6 100644 --- a/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx +++ b/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx @@ -1,11 +1,14 @@ import React, { useEffect } from 'react'; import Animated, { + SharedValue, + useDerivedValue, useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import AnimatedScoreText from './AnimatedScoreText'; import { calculateFontSize, animationDuration, enteringAnimation, ZoomOutFadeOut } from './Helpers'; import { scoreStyles } from './scoreStyles'; @@ -14,20 +17,36 @@ interface Props { currentRoundTotalScore: number; fontColor: string; containerWidth: number; + optimisticCurrentRoundScore?: SharedValue; + optimisticCurrentRoundTotalScore?: SharedValue; } -const ScoreAfter: React.FunctionComponent = ({ containerWidth, currentRoundScore, currentRoundTotalScore, fontColor }) => { +const ScoreAfter: React.FunctionComponent = ({ + containerWidth, + currentRoundScore, + currentRoundTotalScore, + fontColor, + optimisticCurrentRoundScore, + optimisticCurrentRoundTotalScore, +}) => { + const fallbackRoundScore = useSharedValue(currentRoundScore); + const fallbackRoundTotalScore = useSharedValue(currentRoundTotalScore); + const roundScore = optimisticCurrentRoundScore ?? fallbackRoundScore; + const roundTotalScore = optimisticCurrentRoundTotalScore ?? fallbackRoundTotalScore; const fontSize = useSharedValue(calculateFontSize(containerWidth)); const opacity = useSharedValue(1); + const text = useDerivedValue(() => String(roundTotalScore.value)); const animatedStyles = useAnimatedStyle(() => { return { fontSize: fontSize.value, - opacity: opacity.value, + opacity: optimisticCurrentRoundScore ? (roundScore.value === 0 ? 0 : 1) : opacity.value, }; }); useEffect(() => { + fallbackRoundScore.value = currentRoundScore; + fallbackRoundTotalScore.value = currentRoundTotalScore; fontSize.value = withTiming( currentRoundScore == 0 ? 1 : calculateFontSize(containerWidth) * 1.1, { duration: animationDuration }, @@ -40,11 +59,16 @@ const ScoreAfter: React.FunctionComponent = ({ containerWidth, currentRou return ( - - {currentRoundTotalScore} - + style={[animatedStyles, scoreStyles.scoreText, { + color: fontColor, + padding: 0, + textAlign: 'center', + backgroundColor: 'transparent', + }]} + /> ); }; diff --git a/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx b/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx index b51e708a..26cf1c4c 100644 --- a/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx +++ b/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx @@ -1,11 +1,14 @@ import React, { useEffect } from 'react'; import Animated, { + SharedValue, + useDerivedValue, useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import AnimatedScoreText from './AnimatedScoreText'; import { calculateFontSize, animationDuration, enteringAnimation, multiLineScoreSizeMultiplier, singleLineScoreSizeMultiplier, scoreMathOpacity } from './Helpers'; interface Props { @@ -13,30 +16,44 @@ interface Props { currentRoundTotalScore: number; fontColor: string; containerWidth: number; + optimisticCurrentRoundScore?: SharedValue; + optimisticCurrentRoundTotalScore?: SharedValue; } const ScoreBefore: React.FunctionComponent = ({ containerWidth, currentRoundScore, currentRoundTotalScore, - fontColor + fontColor, + optimisticCurrentRoundScore, + optimisticCurrentRoundTotalScore, }) => { - const scoreBefore = currentRoundTotalScore - currentRoundScore; - + const fallbackRoundScore = useSharedValue(currentRoundScore); + const fallbackRoundTotalScore = useSharedValue(currentRoundTotalScore); const fontSize = useSharedValue(calculateFontSize(containerWidth)); const fontOpacity = useSharedValue(100); + const roundScore = optimisticCurrentRoundScore ?? fallbackRoundScore; + const roundTotalScore = optimisticCurrentRoundTotalScore ?? fallbackRoundTotalScore; + const hasOptimisticScore = optimisticCurrentRoundScore != null; + const scoreBefore = useDerivedValue(() => String(roundTotalScore.value - roundScore.value)); + const animatedStyles = useAnimatedStyle(() => { + const scaleFactor = roundScore.value == 0 ? singleLineScoreSizeMultiplier : multiLineScoreSizeMultiplier; return { - fontSize: fontSize.value, - fontWeight: currentRoundScore == 0 ? 'bold' : 'normal', - opacity: fontOpacity.value / 100, + fontSize: hasOptimisticScore ? calculateFontSize(containerWidth) * scaleFactor : fontSize.value, + fontWeight: roundScore.value == 0 ? 'bold' : 'normal', + opacity: hasOptimisticScore + ? (roundScore.value == 0 ? 1 : scoreMathOpacity) + : fontOpacity.value / 100, }; }); const scaleFactor = currentRoundScore == 0 ? singleLineScoreSizeMultiplier : multiLineScoreSizeMultiplier; useEffect(() => { + fallbackRoundScore.value = currentRoundScore; + fallbackRoundTotalScore.value = currentRoundTotalScore; fontSize.value = withTiming( calculateFontSize(containerWidth) * scaleFactor, { duration: animationDuration } @@ -50,15 +67,18 @@ const ScoreBefore: React.FunctionComponent = ({ return ( - - {scoreBefore} - + padding: 0, + textAlign: 'center', + backgroundColor: 'transparent', + }]} + /> ); }; diff --git a/src/components/PlayerTiles/AdditionTile/ScoreRound.tsx b/src/components/PlayerTiles/AdditionTile/ScoreRound.tsx index 641ae073..0278529b 100644 --- a/src/components/PlayerTiles/AdditionTile/ScoreRound.tsx +++ b/src/components/PlayerTiles/AdditionTile/ScoreRound.tsx @@ -1,31 +1,47 @@ import React, { useEffect } from 'react'; import Animated, { + SharedValue, + useDerivedValue, useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import AnimatedScoreText from './AnimatedScoreText'; import { calculateFontSize, animationDuration, multiLineScoreSizeMultiplier, scoreMathOpacity } from './Helpers'; interface Props { currentRoundScore: number; fontColor: string; containerWidth: number; + optimisticCurrentRoundScore?: SharedValue; } -const ScoreRound: React.FunctionComponent = ({ containerWidth, currentRoundScore, fontColor }) => { +const ScoreRound: React.FunctionComponent = ({ + containerWidth, + currentRoundScore, + fontColor, + optimisticCurrentRoundScore, +}) => { + const fallbackRoundScore = useSharedValue(currentRoundScore); + const roundScore = optimisticCurrentRoundScore ?? fallbackRoundScore; const fontSize = useSharedValue(calculateFontSize(containerWidth)); + const text = useDerivedValue(() => { + if (roundScore.value === 0) return ''; + const sign = roundScore.value > 0 ? ' + ' : ' - '; + return sign + Math.abs(roundScore.value); + }); const animatedStyles = useAnimatedStyle(() => { return { fontSize: fontSize.value, + opacity: roundScore.value === 0 ? 0 : scoreMathOpacity, }; }); - const d = currentRoundScore; - useEffect(() => { + fallbackRoundScore.value = currentRoundScore; fontSize.value = withTiming( calculateFontSize(containerWidth) * multiLineScoreSizeMultiplier, { duration: animationDuration } @@ -33,23 +49,20 @@ const ScoreRound: React.FunctionComponent = ({ containerWidth, currentRou }, [currentRoundScore, containerWidth]); - if (currentRoundScore == 0) { - return <>; - } - return ( - - {currentRoundScore > 0 && ' + '} - {currentRoundScore < 0 && ' - '} - {Math.abs(d)} - + padding: 0, + textAlign: 'center', + backgroundColor: 'transparent', + }]} + /> ); };