diff --git a/.maestro/android-only/issue_1154_page_change_focus_repro.yaml b/.maestro/android-only/issue_1154_page_change_focus_repro.yaml new file mode 100644 index 00000000..81cd4423 --- /dev/null +++ b/.maestro/android-only/issue_1154_page_change_focus_repro.yaml @@ -0,0 +1,80 @@ +appId: com.pagerviewexample +tags: + - android + - regression +--- +- runFlow: ../setup/issue_1154_page_change_focus_repro_setup.yaml + +# Leaving a page via setPage must blur its focused input and hide the +# keyboard. Before the fix the off-screen input kept focus and the keyboard. +- tapOn: + id: 'issue-1154-go-1' + +- tapOn: + id: 'issue-1154-input-1' + +- extendedWaitUntil: + visible: + id: 'issue-1154-status' + text: 'Selected: 1 \| Focused: input 1 \| Keyboard: visible' + timeout: 10000 + +- tapOn: + id: 'issue-1154-go-0' + +- extendedWaitUntil: + visible: + id: 'issue-1154-status' + text: 'Selected: 0 \| Focused: none \| Keyboard: hidden' + timeout: 10000 + +# Same when the page is left with a swipe. +- tapOn: + id: 'issue-1154-input-0' + +- extendedWaitUntil: + visible: + id: 'issue-1154-status' + text: 'Selected: 0 \| Focused: input 0 \| Keyboard: visible' + timeout: 10000 + +- swipe: + from: + id: 'issue-1154-label-0' + start: 90%, 50% + end: 10%, 50% + duration: 100 + +- extendedWaitUntil: + visible: + id: 'issue-1154-status' + text: 'Selected: 1 \| Focused: none \| Keyboard: hidden' + timeout: 10000 + +# An input focused right after setPage, while the pager is still animating, +# must keep focus. +- tapOn: + id: 'issue-1154-mode-onPress' + +- tapOn: + id: 'issue-1154-go-0' + +- extendedWaitUntil: + visible: + id: 'issue-1154-status' + text: 'Selected: 0 \| Focused: input 0 \| Keyboard: visible' + timeout: 10000 + +# An input focused from onPageSelected must end up focused with the keyboard +# open. +- tapOn: + id: 'issue-1154-mode-onSelected' + +- tapOn: + id: 'issue-1154-go-1' + +- extendedWaitUntil: + visible: + id: 'issue-1154-status' + text: 'Selected: 1 \| Focused: input 1 \| Keyboard: visible' + timeout: 10000 diff --git a/.maestro/setup/issue_1154_page_change_focus_repro_setup.yaml b/.maestro/setup/issue_1154_page_change_focus_repro_setup.yaml new file mode 100644 index 00000000..9d0ca7c2 --- /dev/null +++ b/.maestro/setup/issue_1154_page_change_focus_repro_setup.yaml @@ -0,0 +1,17 @@ +appId: ${APP_ID} +--- +- launchApp + +# The issue examples sit below the fundamental examples on the home screen. +- scrollUntilVisible: + element: + id: 'Issue #1154 Page Change Focus Repro' + direction: DOWN + +- tapOn: + id: 'Issue #1154 Page Change Focus Repro' + +- extendedWaitUntil: + visible: + id: 'issue-1154-status' + timeout: 10000 diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index 5eaa6069..17e2766b 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -5,6 +5,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration @@ -433,11 +434,33 @@ class ComposePagerView(context: Context) : FrameLayout(context) { return } lastEmittedPageSelected = position + clearFocusOutsidePage(position) UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent( PageSelectedEvent(id, position) ) } + private fun clearFocusOutsidePage(position: Int) { + val focused = findFocus() ?: return + val selectedPage = pages.getOrNull(position) + if (selectedPage?.hasFocus() == true) { + return + } + val inputMethodManager = context.getSystemService(InputMethodManager::class.java) + val wasServingFocused = inputMethodManager?.isActive(focused) == true + clearFocus() + if (hasFocus() && selectedPage?.hasFocus() != true) { + if (selectedPage?.requestFocus(View.FOCUS_FORWARD) != true) { + composeView?.requestFocus(View.FOCUS_FORWARD) + } + } + // The keyboard has to be hidden manually, since unlike ViewPager2's + // RecyclerView in v8, the pager no longer detaches the previous page. + if (wasServingFocused && !focused.hasFocus()) { + inputMethodManager?.hideSoftInputFromWindow(focused.windowToken, 0) + } + } + private fun dispatchPageScroll(position: Int, offset: Float) { UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent( PageScrollEvent(id, position, offset) diff --git a/example/src/App.tsx b/example/src/App.tsx index 8275475a..4efff2a1 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -46,6 +46,7 @@ import { Issue1142PagerListScreen, Issue1142SearchBarInsetRepro, } from './gh-issues/Issue1142SearchBarInsetRepro'; +import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro'; function BasicPagerViewExampleScreen() { return ; @@ -138,6 +139,10 @@ const ghIssues: Example[] = [ component: Issue1142SearchBarInsetRepro, name: 'Issue #1142 Search Bar Inset Repro', }, + { + component: Issue1154PageChangeFocusRepro, + name: 'Issue #1154 Page Change Focus Repro', + }, ]; const allExamples = [ diff --git a/example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx b/example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx new file mode 100644 index 00000000..b5e577a3 --- /dev/null +++ b/example/src/gh-issues/Issue1154PageChangeFocusRepro.tsx @@ -0,0 +1,162 @@ +/** + * Repro for #1154: on Android, a focused TextInput must lose focus and hide + * the keyboard when its page is left, via setPage or a swipe. The mode buttons + * focus the new page's input from onPageSelected or right after setPage, which + * must keep working. + */ +import React, { useEffect, useRef, useState } from 'react'; +import { + Button, + Keyboard, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import PagerView from 'react-native-pager-view'; + +const PAGES = ['Page 0', 'Page 1', 'Page 2']; +const PAGE_COLORS = ['#fde2e2', '#e2fde6', '#e2e8fd']; + +export function Issue1154PageChangeFocusRepro() { + const pagerRef = useRef(null); + const [selectedPage, setSelectedPage] = useState(0); + const [focusedInput, setFocusedInput] = useState(null); + const [keyboardVisible, setKeyboardVisible] = useState(false); + const [events, setEvents] = useState([]); + const [autoFocusMode, setAutoFocusMode] = useState< + 'off' | 'onSelected' | 'onPress' + >('off'); + const inputRefs = useRef>([]); + const log = (event: string) => + setEvents((current) => [...current.slice(-11), event]); + + useEffect(() => { + const show = Keyboard.addListener('keyboardDidShow', () => { + setKeyboardVisible(true); + log('keyboardShow'); + }); + const hide = Keyboard.addListener('keyboardDidHide', () => { + setKeyboardVisible(false); + log('keyboardHide'); + }); + return () => { + show.remove(); + hide.remove(); + }; + }, []); + + return ( + + + Selected: {selectedPage} | Focused:{' '} + {focusedInput === null ? 'none' : `input ${focusedInput}`} | Keyboard:{' '} + {keyboardVisible ? 'visible' : 'hidden'} + + + Events: {events.join(', ')} + + + {PAGES.map((label, i) => ( +