diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index e4943489..fdbb80a2 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { MenuAction, MenuView } from '@react-native-menu/menu'; import { ParamListBase } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { StyleSheet, View } from 'react-native'; +import { Keyboard, StyleSheet, View } from 'react-native'; import { Icon } from 'react-native-elements'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -51,6 +51,11 @@ const FloatingActionButton: React.FunctionComponent = ({ navigation }) => }]}> Keyboard.dismiss()} onPressAction={async ({ nativeEvent }) => { const playerNumber = parseInt(nativeEvent.event); addGameHandler(playerNumber); diff --git a/src/screens/EditPlayerScreen.test.tsx b/src/screens/EditPlayerScreen.test.tsx index 6da3070f..667650b0 100644 --- a/src/screens/EditPlayerScreen.test.tsx +++ b/src/screens/EditPlayerScreen.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { configureStore } from '@reduxjs/toolkit'; import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { Keyboard } from 'react-native'; import { Provider } from 'react-redux'; import gamesReducer from '../../redux/GamesSlice'; @@ -463,6 +464,45 @@ describe('EditPlayerScreen', () => { expect(input).toBeTruthy(); }); + it('dismisses the keyboard when the screen is removed', () => { + // Regression: the clear button focuses the input programmatically. If the + // screen unmounts while it is still the first responder, iOS re-shows the + // keyboard the next time a native menu (the new-game player-count menu) + // presents. Leaving the screen must dismiss the keyboard. + const dismissSpy = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => { }); + + let beforeRemoveCallback: (() => void) | undefined; + mockNavigation.addListener.mockImplementation((event: string, cb: () => void) => { + if (event === 'beforeRemove') { + beforeRemoveCallback = cb; + } + return jest.fn(); + }); + + const mockRoute = { + params: { + index: 0, + playerId: 'player-1', + }, + }; + + render( + + + + ); + + expect(beforeRemoveCallback).toBeDefined(); + + act(() => { + beforeRemoveCallback?.(); + }); + + expect(dismissSpy).toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); + it('should limit input to 15 characters', () => { const store = createMockStore({ settings: { diff --git a/src/screens/EditPlayerScreen.tsx b/src/screens/EditPlayerScreen.tsx index d04ab210..29ff1c63 100644 --- a/src/screens/EditPlayerScreen.tsx +++ b/src/screens/EditPlayerScreen.tsx @@ -1,8 +1,9 @@ -import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ParamListBase, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { + Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, @@ -45,6 +46,10 @@ const EditPlayerScreen: React.FC = ({ navigation.setOptions({ headerLeft: ({ tintColor }) => ( { + // Resign the first responder before the back transition starts, + // while the input is still mounted, so iOS doesn't keep it around + // and re-show the keyboard later (see beforeRemove handler below). + Keyboard.dismiss(); navigation.goBack(); await logEvent('edit_player_back'); }}> @@ -53,6 +58,18 @@ const EditPlayerScreen: React.FC = ({ ), }); }, [navigation, theme.tint]); + + // The clear button focuses the input programmatically. If the screen is + // removed while that input is still the first responder, iOS keeps a + // dangling reference and re-shows the keyboard the next time a native view + // (e.g. the new-game player-count menu) presents. Dismiss it on the way out. + useEffect(() => { + const unsubscribe = navigation.addListener('beforeRemove', () => { + Keyboard.dismiss(); + }); + return unsubscribe; + }, [navigation]); + const dispatch = useAppDispatch(); const currentGame = useAppSelector(selectCurrentGame); const { index, playerId } = route.params;