From 283fad47b170fd5343a0deaea12848283cfe931e Mon Sep 17 00:00:00 2001 From: Justin Wyne <1986068+wyne@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:51:21 -0400 Subject: [PATCH] Optimize TileBoard initial load --- src/components/Boards/PlayerTile.tsx | 3 +- src/components/Boards/TileBoard.test.tsx | 146 +++++++++++------- src/components/Boards/TileBoard.tsx | 133 ++++++++-------- .../PlayerTiles/AdditionTile/Helpers.ts | 7 +- .../PlayerTiles/AdditionTile/ScoreAfter.tsx | 16 +- .../AdditionTile/ScoreAnimations.test.tsx | 65 ++++++++ .../PlayerTiles/AdditionTile/ScoreBefore.tsx | 19 ++- .../PlayerTiles/AdditionTile/ScoreRound.tsx | 10 +- src/screens/GameScreen.test.tsx | 36 ++++- src/screens/GameScreen.tsx | 18 ++- 10 files changed, 302 insertions(+), 151 deletions(-) create mode 100644 src/components/PlayerTiles/AdditionTile/ScoreAnimations.test.tsx diff --git a/src/components/Boards/PlayerTile.tsx b/src/components/Boards/PlayerTile.tsx index fa08cde2..d8ef122a 100644 --- a/src/components/Boards/PlayerTile.tsx +++ b/src/components/Boards/PlayerTile.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { getContrastRatio } from 'colorsheet'; import { DimensionValue, StyleSheet } from 'react-native'; -import Animated, { Easing, FadeIn } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import { shallowEqual } from 'react-redux'; import { selectGameById } from '../../../redux/GamesSlice'; @@ -69,7 +69,6 @@ const PlayerTile: React.FunctionComponent = React.memo(({ return ( ({ bottomSheetHeight: 80, })); -import TileBoard from './TileBoard'; +import TileBoard, { calculateTileBoardLayout, calculateTileDimensions } from './TileBoard'; // Mock react-native-safe-area-context jest.mock('react-native-safe-area-context', () => ({ @@ -80,6 +80,22 @@ const createMockStore = (initialState: Parameters[0]['pre }); }; +const fireBoardLayout = (element: ReturnType['getByTestId']>, width: number, height: number) => { + fireEvent(element, 'layout', { + nativeEvent: { + layout: { + height, + width, + }, + }, + }); +}; + +const fireSettledBoardLayout = (element: ReturnType['getByTestId']>, width: number, height: number) => { + fireBoardLayout(element, width, height); + fireBoardLayout(element, width, height); +}; + describe('TileBoard', () => { const mockGame = { id: 'game-1', @@ -224,7 +240,35 @@ describe('TileBoard', () => { expect(queryByTestId('player-tile-player-1')).toBeNull(); }); - it('should render tiles after layout event with calculated grid', () => { + it('should not render tiles from the first layout measurement', () => { + const store = createMockStore({ + settings: { + currentGameId: 'game-1', + }, + games: { + entities: { + 'game-1': mockGame, + }, + ids: ['game-1'], + }, + players: { + entities: mockPlayers, + ids: ['player-1', 'player-2', 'player-3', 'player-4'], + }, + }); + + const { getByTestId, queryByTestId } = render( + + + + ); + + fireBoardLayout(getByTestId('safe-area-view'), 400, 600); + + expect(queryByTestId('player-tile-player-1')).toBeNull(); + }); + + it('should render tiles after settled layout with calculated grid', () => { const store = createMockStore({ settings: { currentGameId: 'game-1', @@ -250,14 +294,7 @@ describe('TileBoard', () => { const safeAreaView = getByTestId('safe-area-view'); // Trigger layout event with specific dimensions - fireEvent(safeAreaView, 'layout', { - nativeEvent: { - layout: { - width: 400, - height: 600, - }, - }, - }); + fireSettledBoardLayout(safeAreaView, 400, 600); // After layout, tiles should be rendered expect(getByTestId('player-tile-player-1')).toBeTruthy(); @@ -296,14 +333,7 @@ describe('TileBoard', () => { const safeAreaView = getByTestId('safe-area-view'); // Trigger layout with 400x600 dimensions - fireEvent(safeAreaView, 'layout', { - nativeEvent: { - layout: { - width: 400, - height: 600, - }, - }, - }); + fireSettledBoardLayout(safeAreaView, 400, 600); // For 2x2 grid: width = 400/2 = 200, height = 600/2 = 300 expect(getAllByText('Width: 200')).toHaveLength(4); @@ -339,14 +369,7 @@ describe('TileBoard', () => { const safeAreaView = getByTestId('safe-area-view'); // Trigger layout event - fireEvent(safeAreaView, 'layout', { - nativeEvent: { - layout: { - width: 300, - height: 400, - }, - }, - }); + fireSettledBoardLayout(safeAreaView, 300, 400); // For 3 players, should calculate optimal grid (likely 3x1 or 1x3) expect(getByTestId('player-tile-player-1')).toBeTruthy(); @@ -388,14 +411,7 @@ describe('TileBoard', () => { const safeAreaView = getByTestId('safe-area-view'); - fireEvent(safeAreaView, 'layout', { - nativeEvent: { - layout: { - width: 300, - height: 400, - }, - }, - }); + fireSettledBoardLayout(safeAreaView, 300, 400); expect(getByTestId('player-tile-player-1')).toBeTruthy(); // Single player should be 1x1 grid @@ -463,14 +479,7 @@ describe('TileBoard', () => { const safeAreaView = getByTestId('safe-area-view'); - fireEvent(safeAreaView, 'layout', { - nativeEvent: { - layout: { - width: 400, - height: 400, - }, - }, - }); + fireSettledBoardLayout(safeAreaView, 400, 400); // Check that each tile has the correct index expect(getByText('Index: 0')).toBeTruthy(); // player-1 @@ -508,29 +517,50 @@ describe('TileBoard', () => { const safeAreaView = getByTestId('safe-area-view'); // Initial layout - fireEvent(safeAreaView, 'layout', { - nativeEvent: { - layout: { - width: 200, - height: 400, - }, - }, - }); + fireSettledBoardLayout(safeAreaView, 200, 400); // Should initially have certain dimensions (1x2 grid: width=200/1=200, height=400/2=200) expect(getAllByText('Width: 200')).toHaveLength(2); // Change layout dimensions - fireEvent(safeAreaView, 'layout', { - nativeEvent: { - layout: { - width: 400, - height: 200, - }, - }, - }); + fireBoardLayout(safeAreaView, 400, 200); // Should recalculate and update dimensions (2x1 grid: width=400/2=200, height=200/1=200) expect(getAllByText('Width: 200')).toHaveLength(2); }); + + describe('layout helpers', () => { + it('calculates a square grid and dimensions for four players', () => { + const layout = calculateTileBoardLayout(4, 400, 600); + + expect(layout).toEqual({ + cols: 2, + height: 600, + rows: 2, + width: 400, + }); + expect(calculateTileDimensions(layout)).toEqual({ + height: 300, + width: 200, + }); + }); + + it('keeps a two-player board balanced for portrait and landscape layouts', () => { + const portraitLayout = calculateTileBoardLayout(2, 200, 400); + const landscapeLayout = calculateTileBoardLayout(2, 400, 200); + + expect(portraitLayout).toEqual({ + cols: 1, + height: 400, + rows: 2, + width: 200, + }); + expect(landscapeLayout).toEqual({ + cols: 2, + height: 200, + rows: 1, + width: 400, + }); + }); + }); }); diff --git a/src/components/Boards/TileBoard.tsx b/src/components/Boards/TileBoard.tsx index 72e63d9d..b126267a 100644 --- a/src/components/Boards/TileBoard.tsx +++ b/src/components/Boards/TileBoard.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useState } from 'react'; +import React, { memo, useState } from 'react'; import { LayoutChangeEvent, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -20,70 +20,27 @@ const TileBoard: React.FC<{ showHint: boolean }> = ({ showHint }) => { if (playerIds == null || playerIds.length == 0) return null; - const [rows, setRows] = useState(0); - const [cols, setCols] = useState(0); - - const [width, setWidth] = useState(null); - const [height, setHeight] = useState(null); - const playerCount = playerIds.length; - - const desiredAspectRatio = 1; + const [layoutState, setLayoutState] = useState({ + layout: null, + measurementCount: 0, + }); const layoutHandler = (e: LayoutChangeEvent) => { const { width, height } = e.nativeEvent.layout; + const layout = calculateTileBoardLayout(playerCount, Math.round(width), Math.round(height)); - setWidth(Math.round(width)); - setHeight(Math.round(height)); - calcGrid(); + setLayoutState((previous) => ({ + layout, + measurementCount: previous.measurementCount + 1, + })); }; - const calcGrid = () => { - let closestAspectRatio = Number.MAX_SAFE_INTEGER; - let bestRowCount = 1; - - if (width == null || height == null) return; - - for (let rows = 1; rows <= playerIds.length; rows++) { - const cols = Math.ceil(playerIds.length / rows); - - if (playerIds.length % rows > 0 && rows - playerIds.length % rows > 1) { - continue; - } - - const w = width / cols; - const h = height / rows; - const ratio = w / h; - - if (Math.abs(desiredAspectRatio - ratio) < Math.abs(desiredAspectRatio - closestAspectRatio)) { - closestAspectRatio = ratio; - bestRowCount = rows; - } - } - - setRows(bestRowCount); - setCols(Math.ceil(playerIds.length / bestRowCount)); - }; - - useEffect(() => { - if (width == null || height == null) return; - calcGrid(); - }, [playerCount, width, height]); - - type DimensionValue = (rows: number, cols: number) => { - width: number; - height: number; - }; - - const calculateTileDimensions: DimensionValue = (rows: number, cols: number) => { - if (width == null || height == null) return { width: 0, height: 0 }; - - const dims = { - width: Math.round(width / cols), - height: Math.round(height / rows) - }; - return dims; - }; + const { layout, measurementCount } = layoutState; + const layoutReady = measurementCount > 1; + const tileDimensions = layout == null + ? null + : calculateTileDimensions(layout); return ( = ({ showHint }) => { }] } onLayout={layoutHandler} > {playerIds.map((id, index) => ( - width != null && height != null && rows != 0 && cols != 0 && + layoutReady && layout != null && tileDimensions != null && @@ -110,6 +67,56 @@ const TileBoard: React.FC<{ showHint: boolean }> = ({ showHint }) => { ); }; +interface TileBoardLayout { + cols: number; + height: number; + rows: number; + width: number; +} + +interface TileBoardLayoutState { + layout: TileBoardLayout | null; + measurementCount: number; +} + +const desiredAspectRatio = 1; + +export const calculateTileBoardLayout = (playerCount: number, width: number, height: number): TileBoardLayout => { + let closestAspectRatio = Number.MAX_SAFE_INTEGER; + let bestRowCount = 1; + + for (let rows = 1; rows <= playerCount; rows++) { + const cols = Math.ceil(playerCount / rows); + + if (playerCount % rows > 0 && rows - playerCount % rows > 1) { + continue; + } + + const w = width / cols; + const h = height / rows; + const ratio = w / h; + + if (Math.abs(desiredAspectRatio - ratio) < Math.abs(desiredAspectRatio - closestAspectRatio)) { + closestAspectRatio = ratio; + bestRowCount = rows; + } + } + + return { + cols: Math.ceil(playerCount / bestRowCount), + height, + rows: bestRowCount, + width, + }; +}; + +export const calculateTileDimensions = (layout: TileBoardLayout) => { + return { + height: Math.round(layout.height / layout.rows), + width: Math.round(layout.width / layout.cols), + }; +}; + const styles = StyleSheet.create({ contentStyle: { flex: 1, diff --git a/src/components/PlayerTiles/AdditionTile/Helpers.ts b/src/components/PlayerTiles/AdditionTile/Helpers.ts index b6d4da36..fe41b487 100644 --- a/src/components/PlayerTiles/AdditionTile/Helpers.ts +++ b/src/components/PlayerTiles/AdditionTile/Helpers.ts @@ -1,15 +1,10 @@ -import { ZoomIn, withTiming } from 'react-native-reanimated'; +import { withTiming } from 'react-native-reanimated'; /** * The duration of the animation in milliseconds. */ export const animationDuration = 200; -/** - * The duration of the entering animation in milliseconds. - */ -export const enteringAnimation = ZoomIn.duration(animationDuration); - export const singleLineScoreSizeMultiplier = 1.2; export const multiLineScoreSizeMultiplier = 0.7; diff --git a/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx b/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx index 338d3747..b33a5c1a 100644 --- a/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx +++ b/src/components/PlayerTiles/AdditionTile/ScoreAfter.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import Animated, { useSharedValue, @@ -6,7 +6,7 @@ import Animated, { withTiming } from 'react-native-reanimated'; -import { calculateFontSize, animationDuration, enteringAnimation, ZoomOutFadeOut } from './Helpers'; +import { calculateFontSize, animationDuration, ZoomOutFadeOut } from './Helpers'; import { scoreStyles } from './scoreStyles'; interface Props { @@ -17,8 +17,9 @@ interface Props { } const ScoreAfter: React.FunctionComponent = ({ containerWidth, currentRoundScore, currentRoundTotalScore, fontColor }) => { - const fontSize = useSharedValue(calculateFontSize(containerWidth)); - const opacity = useSharedValue(1); + const initialRender = useRef(true); + const fontSize = useSharedValue(currentRoundScore == 0 ? 1 : calculateFontSize(containerWidth) * 1.1); + const opacity = useSharedValue(currentRoundScore == 0 ? 0 : 1); const animatedStyles = useAnimatedStyle(() => { return { @@ -28,6 +29,11 @@ const ScoreAfter: React.FunctionComponent = ({ containerWidth, currentRou }); useEffect(() => { + if (initialRender.current) { + initialRender.current = false; + return; + } + fontSize.value = withTiming( currentRoundScore == 0 ? 1 : calculateFontSize(containerWidth) * 1.1, { duration: animationDuration }, @@ -39,7 +45,7 @@ const ScoreAfter: React.FunctionComponent = ({ containerWidth, currentRou }, [currentRoundScore, containerWidth]); return ( - + diff --git a/src/components/PlayerTiles/AdditionTile/ScoreAnimations.test.tsx b/src/components/PlayerTiles/AdditionTile/ScoreAnimations.test.tsx new file mode 100644 index 00000000..7d857e35 --- /dev/null +++ b/src/components/PlayerTiles/AdditionTile/ScoreAnimations.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { render } from '@testing-library/react-native'; +import { withTiming } from 'react-native-reanimated'; + +import ScoreAfter from './ScoreAfter'; +import ScoreBefore from './ScoreBefore'; +import ScoreRound from './ScoreRound'; + +jest.mock('react-native-reanimated', () => { + const { Text, View } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: { + Text, + View, + }, + useAnimatedStyle: jest.fn((callback) => callback()), + useSharedValue: jest.fn((value) => ({ value })), + withTiming: jest.fn((value) => value), + }; +}); + +const mockedWithTiming = withTiming as jest.Mock; + +describe('AdditionTile score animations', () => { + beforeEach(() => { + mockedWithTiming.mockClear(); + }); + + it('renders initial zero scores without timing animations', () => { + render( + <> + + + + + ); + + expect(mockedWithTiming).not.toHaveBeenCalled(); + }); + + it('animates score values after the initial render', () => { + const { rerender } = render( + <> + + + + + ); + + mockedWithTiming.mockClear(); + + rerender( + <> + + + + + ); + + expect(mockedWithTiming).toHaveBeenCalled(); + }); +}); diff --git a/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx b/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx index b51e708a..539ba0fa 100644 --- a/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx +++ b/src/components/PlayerTiles/AdditionTile/ScoreBefore.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import Animated, { useSharedValue, @@ -6,7 +6,7 @@ import Animated, { withTiming } from 'react-native-reanimated'; -import { calculateFontSize, animationDuration, enteringAnimation, multiLineScoreSizeMultiplier, singleLineScoreSizeMultiplier, scoreMathOpacity } from './Helpers'; +import { calculateFontSize, animationDuration, multiLineScoreSizeMultiplier, singleLineScoreSizeMultiplier, scoreMathOpacity } from './Helpers'; interface Props { currentRoundScore: number; @@ -21,10 +21,12 @@ const ScoreBefore: React.FunctionComponent = ({ currentRoundTotalScore, fontColor }) => { + const initialRender = useRef(true); const scoreBefore = currentRoundTotalScore - currentRoundScore; + const scaleFactor = currentRoundScore == 0 ? singleLineScoreSizeMultiplier : multiLineScoreSizeMultiplier; - const fontSize = useSharedValue(calculateFontSize(containerWidth)); - const fontOpacity = useSharedValue(100); + const fontSize = useSharedValue(calculateFontSize(containerWidth) * scaleFactor); + const fontOpacity = useSharedValue(currentRoundScore == 0 ? 100 : scoreMathOpacity * 100); const animatedStyles = useAnimatedStyle(() => { return { @@ -34,9 +36,12 @@ const ScoreBefore: React.FunctionComponent = ({ }; }); - const scaleFactor = currentRoundScore == 0 ? singleLineScoreSizeMultiplier : multiLineScoreSizeMultiplier; - useEffect(() => { + if (initialRender.current) { + initialRender.current = false; + return; + } + fontSize.value = withTiming( calculateFontSize(containerWidth) * scaleFactor, { duration: animationDuration } @@ -49,7 +54,7 @@ const ScoreBefore: React.FunctionComponent = ({ }, [currentRoundScore, containerWidth]); return ( - + = ({ containerWidth, currentRoundScore, fontColor }) => { - const fontSize = useSharedValue(calculateFontSize(containerWidth)); + const initialRender = useRef(true); + const fontSize = useSharedValue(calculateFontSize(containerWidth) * multiLineScoreSizeMultiplier); const animatedStyles = useAnimatedStyle(() => { return { @@ -26,6 +27,11 @@ const ScoreRound: React.FunctionComponent = ({ containerWidth, currentRou const d = currentRoundScore; useEffect(() => { + if (initialRender.current) { + initialRender.current = false; + return; + } + fontSize.value = withTiming( calculateFontSize(containerWidth) * multiLineScoreSizeMultiplier, { duration: animationDuration } diff --git a/src/screens/GameScreen.test.tsx b/src/screens/GameScreen.test.tsx index f36f6494..43c7a115 100644 --- a/src/screens/GameScreen.test.tsx +++ b/src/screens/GameScreen.test.tsx @@ -10,8 +10,10 @@ import settingsReducer from '../../redux/SettingsSlice'; import GameScreen from './GameScreen'; +let mockHeaderHeight = 88; + jest.mock('@react-navigation/elements', () => ({ - useHeaderHeight: () => 88, + useHeaderHeight: () => mockHeaderHeight, })); jest.mock('react-native-reanimated', () => { @@ -99,6 +101,10 @@ const createMockStore = (initialState: Parameters[0]['pre }; describe('GameScreen', () => { + beforeEach(() => { + mockHeaderHeight = 88; + }); + const mockGame = { id: 'game-1', title: 'Test Game', @@ -168,8 +174,8 @@ describe('GameScreen', () => { ); - expect(getByTestId('tile-board')).toBeTruthy(); expect(getByTestId('point-values-sheet')).toBeTruthy(); + expect(getByTestId('tile-board')).toBeTruthy(); }); it('should render ListBoard when interactionType is Dial', () => { @@ -196,4 +202,30 @@ describe('GameScreen', () => { expect(getByTestId('list-board')).toBeTruthy(); }); + + it('should use the measured transparent header inset', () => { + const store = createMockStore({ + settings: { + currentGameId: 'game-1', + }, + games: { + entities: { 'game-1': mockGame }, + ids: ['game-1'], + }, + players: { + entities: mockPlayers, + ids: ['player-1', 'player-2'], + }, + }); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('game-screen').props.style.paddingTop).toBe(88); + expect(queryByTestId('point-values-sheet')).toBeTruthy(); + }); + }); diff --git a/src/screens/GameScreen.tsx b/src/screens/GameScreen.tsx index 15423198..13e86e74 100644 --- a/src/screens/GameScreen.tsx +++ b/src/screens/GameScreen.tsx @@ -41,12 +41,18 @@ const GameScreen: React.FunctionComponent = () => { {interactionType === InteractionType.Dial - ? - - - : - - + ? ( + + + + ) + : ( + + + + ) }