diff --git a/.changeset/fuzzy-tvs-focus.md b/.changeset/fuzzy-tvs-focus.md new file mode 100644 index 00000000..ccc6eeb9 --- /dev/null +++ b/.changeset/fuzzy-tvs-focus.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': patch +--- + +Restore the previously focused content after closing an adaptable sidebar on tvOS. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 204cd8d3..c6b5b4c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: - name: Typecheck files run: yarn typecheck + - name: Validate issue 501 repro + run: | + yarn tsc --project evidence/issue-501/repro/tsconfig.json + yarn eslint evidence/issue-501/repro/App.tsx + swift-lint: runs-on: ubuntu-latest steps: @@ -108,7 +113,6 @@ jobs: run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" - build-ios: runs-on: macos-15 env: @@ -123,6 +127,17 @@ jobs: - name: Build package run: yarn build + - name: Typecheck tvOS view wrapper + run: | + mkdir -p "$RUNNER_TEMP/swift-module-cache" + xcrun --sdk appletvsimulator swiftc \ + -module-cache-path "$RUNNER_TEMP/swift-module-cache" \ + -target arm64-apple-tvos15.1-simulator \ + -sdk "$(xcrun --sdk appletvsimulator --show-sdk-path)" \ + -typecheck \ + packages/react-native-bottom-tabs/ios/PlatformAliases.swift \ + packages/react-native-bottom-tabs/ios/RepresentableView.swift + - name: Cache turborepo for iOS uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: @@ -152,4 +167,3 @@ jobs: - name: Build example for iOS run: | yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" - diff --git a/evidence/issue-501/README.md b/evidence/issue-501/README.md new file mode 100644 index 00000000..21ff1e25 --- /dev/null +++ b/evidence/issue-501/README.md @@ -0,0 +1,54 @@ +# Issue #501 tvOS focus restoration evidence + +## Environment + +- macOS 26.6.2 +- Xcode 26.6 (17F113), selected explicitly with `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` +- Apple TV simulator, tvOS 26.5 +- `agent-device` 0.20.10 +- React 19.2.3 +- `react-native-tvos` 0.83.1-0 +- Hermes and the New Architecture enabled +- `react-native-bottom-tabs` and `@bottom-tabs/react-navigation` 1.4.0 built from this worktree +- `react-native-screens` 4.18.0 and `react-native-safe-area-context` 5.6.1 + +The standalone app was generated with: + +```sh +npx --yes @react-native-community/cli@20.0.2 init TVFocusRepro --template @react-native-tvos/template-tv@0.83.0-0 --skip-install +``` + +Its screen implementation is preserved in [`repro/App.tsx`](repro/App.tsx). The Podfile used `platform :tvos`, and `RCT_NEW_ARCH_ENABLED=1 pod install` generated the native project. A test-harness-only post-install setting selected C++17 for the `fmt` pod because that version of the template otherwise fails to compile under Xcode 26.6; the library change does not alter C++ settings. + +## Exact reproduction + +1. Launch the app with the `Watching` route selected, `sidebarAdaptable` enabled, and `freezeOnBlur: true`. +2. Move Right from the selected sidebar tab into the list. +3. Move Down eight times so `Button 8` is focused. +4. Move Left to focus `Watching` in the expanded sidebar. +5. Move Right to close the sidebar and return to the list. + +Before the fix, the semantic snapshots reported `Button 8` before opening the sidebar and `Button 0` after closing it. This matches the issue recording. See [`issue-501-baseline.mp4`](issue-501-baseline.mp4), [`baseline-focus.txt`](baseline-focus.txt), [`screenshots/issue-501-baseline-sidebar.png`](screenshots/issue-501-baseline-sidebar.png), and [`screenshots/issue-501-baseline-after-return.png`](screenshots/issue-501-baseline-after-return.png). + +## Verification after the fix + +- The same sequence returns focus to `Button 8`. +- Two additional Left/Right sidebar cycles continue to return to `Button 8`. +- After scrolling farther, a Left/Right cycle returns to `Button 15`, retaining the scroll position. +- A fresh install preserves the existing initial behavior: focus begins on the selected `Watching` sidebar tab, and Right enters the list at `Button 0` because no prior descendant exists. +- Switching from `Watching` to `Search` and back while `freezeOnBlur` is enabled uses the route's default `Button 0`; it does not target the stale `Button 5` from the previously active route. +- The app compiles for the repository's tvOS 15.1 deployment target with Xcode 26.6 and runs on tvOS 26.5 with Hermes and the New Architecture. +- iOS, macOS, visionOS, and Android behavior is unchanged because the focus-restoring wrapper is compiled only for tvOS. + +See [`issue-501-fixed.mp4`](issue-501-fixed.mp4), [`fixed-focus.txt`](fixed-focus.txt), and [`screenshots/issue-501-fixed-button15.png`](screenshots/issue-501-fixed-button15.png). + +## Repository checks + +- `yarn lint` — passed with the repository's three existing warnings. +- `yarn typecheck` — passed. +- `yarn build` — passed. +- `yarn build:ios` — passed. +- `yarn build:android` — passed, including the New Architecture native build. +- `yarn tsc --project evidence/issue-501/repro/tsconfig.json` — passed. +- Standalone tvOS Xcode build — passed. +- `yarn test` — the repository command exits 1 before running tests because `@bottom-tabs/react-navigation` contains no tests. There is no native XCTest target in the library, so the focused regression is covered by the recorded simulator flow and semantic focus snapshots. diff --git a/evidence/issue-501/baseline-focus.txt b/evidence/issue-501/baseline-focus.txt new file mode 100644 index 00000000..021714ae --- /dev/null +++ b/evidence/issue-501/baseline-focus.txt @@ -0,0 +1,8 @@ +Before opening the sidebar: +@e13 [cell] "Button 8" [focused] + +After moving Left into the sidebar: +@e17 [button] "Watching" [focused] + +After moving Right back to the content: +@e5 [cell] "Button 0" [focused] diff --git a/evidence/issue-501/fixed-focus.txt b/evidence/issue-501/fixed-focus.txt new file mode 100644 index 00000000..1d591e09 --- /dev/null +++ b/evidence/issue-501/fixed-focus.txt @@ -0,0 +1,14 @@ +After moving Down eight times: +@e13 [cell] "Button 8" [focused] + +After moving Left into the sidebar and Right back to the content: +@e13 [cell] "Button 8" [focused] + +After two additional sidebar round trips: +@e13 [cell] "Button 8" [focused] + +After scrolling farther and completing another sidebar round trip: +@e15 [cell] "Button 15" [focused] + +After a fresh install, switching from Watching to Search, and switching back: +@e5 [cell] "Button 0" [focused] diff --git a/evidence/issue-501/issue-501-baseline.mp4 b/evidence/issue-501/issue-501-baseline.mp4 new file mode 100644 index 00000000..78fc59da Binary files /dev/null and b/evidence/issue-501/issue-501-baseline.mp4 differ diff --git a/evidence/issue-501/issue-501-fixed.mp4 b/evidence/issue-501/issue-501-fixed.mp4 new file mode 100644 index 00000000..1f662421 Binary files /dev/null and b/evidence/issue-501/issue-501-fixed.mp4 differ diff --git a/evidence/issue-501/remediation.md b/evidence/issue-501/remediation.md new file mode 100644 index 00000000..9d2389f0 --- /dev/null +++ b/evidence/issue-501/remediation.md @@ -0,0 +1,36 @@ +# Issue #501 remediation + +## Finding dispositions + +1. **Resolved — recycled Fabric view identity.** `FocusRestoringView` now records the focused view's React tag and requires the current tag to match before returning that view from `preferredFocusEnvironments`. A recycled `UIView` whose tag has changed can no longer redirect focus to a different React element. + +2. **Resolved — tvOS compile coverage.** The macOS CI job now invokes `swiftc` for an `arm64-apple-tvos15.1-simulator` target over `PlatformAliases.swift` and `RepresentableView.swift`. This directly compiles the `#if os(tvOS)` implementation at the library's declared tvOS deployment target without adding another example application or changing dependency versions. + +3. **Resolved — release metadata.** `.changeset/fuzzy-tvs-focus.md` adds a patch changeset for `react-native-bottom-tabs`. The repository's fixed/dependent package rules propagate the release as shown by `yarn changeset status`. + +4. **Resolved within the required evidence scope — evidence size, duplication, and repro verification.** The issue-specific directory is retained because the remediation requires the committed before/after recordings, useful screenshots, repro source, semantic snapshots, review JSON, and exact notes. The byte-identical `screenshots/issue-501-baseline-initial.png` was removed; its SHA-256 was the same as the retained and accurately named `screenshots/issue-501-baseline-after-return.png` (`054a9995b635dcc46e3d106640fb5f2e6505f7a16d673bf3f9a31d421276a913`). The reporter's `tvParallaxProperties` setting remains in the repro through a narrowly typed local wrapper because the repository typechecks against core React Native rather than the extended `react-native-tvos` declarations. `repro/tsconfig.json` makes the complete source independently typecheckable, and CI now typechecks and lints it. Both HEVC recordings, the three distinct screenshots, both semantic snapshots, the repro, the review, and the reproduction notes remain under `evidence/issue-501/`. + +5. **Resolved — Swift brace style.** The multiline `if let` opening brace is now on the final condition line, matching the configured SwiftLint `opening_brace` rule. + +6. **Unchanged — inapplicable to supported architectures.** The reported failure requires React Native's Paper architecture. This repository removed old-architecture support in commit `4b4e781cead514784c46599ab09554fad6c41208` (`feat!: drop old architecture`), recorded in `packages/react-native-bottom-tabs/CHANGELOG.md`. That commit deleted `RCTTabViewViewManager.mm`, removed the podspec's architecture conditional, and made New Architecture module installation unconditional. The current `RCTTabViewComponentView.mm` mounts and unmounts Fabric children through `insertChild` and `removeChildAtIndex`, which preserve the identities of unaffected children; it does not use `didUpdateReactSubviews`. Changing `IdentifiablePlatformView.id` for an unsupported Paper-only callback would therefore be a broader, unverified behavior change rather than a fix for a supported configuration. + +## Validation performed + +- `yarn lint` — passed with the three pre-existing warnings in `TabView.tsx` and `TabViewNativeComponent.ts` and no errors. +- `yarn typecheck` — passed. +- `yarn build` — passed. +- `yarn build:ios` — passed. +- `yarn build:android` — passed, including the New Architecture native build. +- `yarn tsc --project evidence/issue-501/repro/tsconfig.json` — passed. +- `yarn eslint evidence/issue-501/repro/App.tsx` — passed. +- `yarn prettier --check .github/workflows/ci.yml .changeset/fuzzy-tvs-focus.md evidence/issue-501/README.md evidence/issue-501/repro/App.tsx evidence/issue-501/repro/tsconfig.json` — passed. +- `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml")'` — passed. +- The standalone Hermes/Fabric repro rebuilt successfully with Xcode 26.6 (17F113), the tvOS 26.5 SDK, and a tvOS 15.1 deployment target after installing the final packed library. +- `xcrun --sdk appletvsimulator swiftc -module-cache-path /tmp/issue-501-swift-module-cache -target arm64-apple-tvos15.1-simulator -sdk "$(xcrun --sdk appletvsimulator --show-sdk-path)" -typecheck packages/react-native-bottom-tabs/ios/PlatformAliases.swift packages/react-native-bottom-tabs/ios/RepresentableView.swift` — passed with Xcode 26.6 and the tvOS 26.5 SDK while targeting tvOS 15.1. +- Equivalent `swiftc -typecheck` invocations passed for iOS 14.0 Simulator, macOS 11.0, and visionOS 1.0 Simulator targets. +- `yarn changeset status` — passed and reported patch bumps for `react-native-bottom-tabs` plus packages propagated by the repository's release configuration. +- `jq -e '.findings | length == 6 and all(.[]; has("file") and has("line") and has("defect") and has("failure_scenario") and has("severity"))' evidence/issue-501/review.json` — returned `true`. +- `git diff --check` — passed. +- `ffprobe` confirmed both retained videos are readable 1920×1080 HEVC recordings: baseline duration 19.67 seconds and final fixed duration 61.83 seconds. Contact-sheet inspection confirmed the baseline failure and final fixed focus sequences. `shasum -a 256` confirmed the removed screenshot was the sole duplicate among the screenshots. + +SwiftLint is not installed in this sandbox, so its executable was not run locally. The post-remediation `agent-device` replay on tvOS 26.5 verified two `Button 8` sidebar round trips, the route-switch default of `Button 0`, and scrolled restoration to `Button 15`; it replaced the retained fixed recording and screenshot with evidence from the final code. diff --git a/evidence/issue-501/repro/App.tsx b/evidence/issue-501/repro/App.tsx new file mode 100644 index 00000000..e9d69da0 --- /dev/null +++ b/evidence/issue-501/repro/App.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; + +const Tabs = createNativeBottomTabNavigator(); +const TVPressable = Pressable as React.ComponentType< + React.ComponentProps & { + tvParallaxProperties?: { magnification?: number }; + } +>; + +function WatchingScreen() { + const [focusedIndex, setFocusedIndex] = React.useState(null); + + return ( + + + Focused: {focusedIndex === null ? 'none' : `Button ${focusedIndex}`} + + + {Array.from({ length: 20 }, (_, index) => ( + setFocusedIndex(index)} + style={[ + styles.button, + focusedIndex === index && styles.focusedButton, + ]} + testID={`button-${index}`} + tvParallaxProperties={{ magnification: 1.1 }} + > + + Button {index} + + + ))} + + + ); +} + +function SearchScreen() { + return ( + + + Search action + + + ); +} + +function NewScreen() { + return ( + + + New action + + + ); +} + +function App() { + return ( + + + ({ sfSymbol: 'star.fill' }), + }} + /> + ({ sfSymbol: 'magnifyingglass' }), + }} + /> + ({ sfSymbol: 'plus' }), + }} + /> + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: '#101114', + }, + status: { + color: 'white', + fontSize: 24, + marginHorizontal: 80, + marginTop: 30, + }, + content: { + gap: 24, + padding: 80, + }, + button: { + alignItems: 'center', + backgroundColor: '#30343b', + borderRadius: 10, + minHeight: 64, + justifyContent: 'center', + paddingHorizontal: 30, + }, + focusedButton: { + backgroundColor: '#f4f4f4', + transform: [{ scale: 1.04 }], + }, + buttonText: { + color: '#f4f4f4', + fontSize: 22, + fontWeight: '600', + }, + focusedButtonText: { + color: '#111217', + }, + center: { + alignItems: 'center', + backgroundColor: '#101114', + flex: 1, + justifyContent: 'center', + padding: 80, + }, +}); + +export default App; diff --git a/evidence/issue-501/repro/tsconfig.json b/evidence/issue-501/repro/tsconfig.json new file mode 100644 index 00000000..5519a710 --- /dev/null +++ b/evidence/issue-501/repro/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": ["App.tsx"] +} diff --git a/evidence/issue-501/review.json b/evidence/issue-501/review.json new file mode 100644 index 00000000..86a313b7 --- /dev/null +++ b/evidence/issue-501/review.json @@ -0,0 +1,46 @@ +{ + "findings": [ + { + "file": "packages/react-native-bottom-tabs/ios/RepresentableView.swift", + "line": 9, + "defect": "The guard validates that `lastFocusedView` is in the window, a descendant, and focusable, but never that the UIView still represents the same React element. Under Fabric, unmounted views are kept alive in a LIFO recycle pool (RCTComponentViewRegistry.mm:99-118, `prepareForRecycle` keeps the object alive so the weak ref does not nil out) and are re-issued with a new `tag` (RCTComponentViewRegistry.mm:53). Identity of the UIView is therefore not identity of the focused item. The evidence README (line 39) presents 'switching tabs uses the route default, not stale Button 5' as verified behavior, but nothing in the code enforces it; it is the incidental result of the pooled view failing `window != nil` at that instant.", + "failure_scenario": "tvOS, New Architecture, `freezeOnBlur: true` (exactly the repro config). 1) In Watching, focus Button 8. 2) Switch to Search: react-freeze sets display:none, Fabric marks the subtree Trait::Hidden and on iOS skips it from mounting (sliceChildShadowNodeViewPairs.cpp:62), so Button 8's RCTViewComponentView is enqueued into the View recycle pool; the wrapper's weak ref stays non-nil. 3) Switch back to Watching: Fabric dequeues views LIFO, so Button 8's old native view is reused for whichever / mounts first in the Watching tree. If that element is a Pressable (e.g. Button 0 or the status row), `window != nil`, `isDescendant`, and `canBecomeFocused` all pass and the wrapper redirects focus to that arbitrary element instead of the default. Same failure without freezeOnBlur: open the sidebar, let the list refresh and remove the focused item while the sidebar is open, a new item takes the recycled view, press Right, focus lands on the new item. Fix: store `lastFocusedView.tag` when capturing and add `lastFocusedView.tag == lastFocusedTag` to the guard.", + "severity": "medium" + }, + { + "file": ".github/workflows/ci.yml", + "line": 112, + "defect": "No CI job compiles the `#if os(tvOS)` block. `build-ios` builds apps/example, which depends on plain `react-native` 0.81.4 with no tvOS target, so FocusRestoringView is dead code to every CI job. The change is verified only by a manual, non-reproducible local build described in evidence/issue-501/README.md. The repo has already shipped tvOS-only compile regressions twice (CHANGELOG entries for #312 and #508).", + "failure_scenario": "Any later edit to FocusRestoringView (rename, availability change, Swift 6 strict-concurrency error on the UIView override) passes lint, typecheck, build-library, build-android and build-ios, is released, and fails `pod install`/xcodebuild for every react-native-tvos consumer. A tvOS xcodebuild step (or a tvOS scheme in the example app) is required for this file to have any coverage.", + "severity": "medium" + }, + { + "file": ".changeset/config.json", + "line": 1, + "defect": "The diff contains no `.changeset/*.md`. Every fix in recent history adds one (e.g. android-tab-test-ids.md, fix-compiler-guards-ios26.md), and `publish-packages` runs `changeset version && changeset publish` with both packages in a `fixed` group.", + "failure_scenario": "After merge, `changeset version` produces no bump and no CHANGELOG entry for this fix. It either does not publish until an unrelated changeset lands, or ships silently with no release note, so tvOS users tracking issue 501 cannot tell which version contains it.", + "severity": "medium" + }, + { + "file": "evidence/issue-501/README.md", + "line": 31, + "defect": "The untracked `evidence/` directory (3.1 MB: two .mp4 files totalling 2.8 MB plus four PNGs) is at the repo root, is not covered by .gitignore, and is outside every yarn workspace. `screenshots/issue-501-baseline-initial.png` and `screenshots/issue-501-baseline-after-return.png` are byte-identical (`cmp` reports IDENTICAL), so the 'after return' screenshot cited as evidence is not independent evidence. `repro/App.tsx` is not linted or typechecked by any turbo task (per-package eslint/tsc only) and uses `tvParallaxProperties`, which does not exist in the repo's react-native 0.81 Pressable types, so it would fail typecheck if ever included.", + "failure_scenario": "Committing this directory permanently adds ~3 MB of binary blobs to every clone and fetch of the repository with no consumer in the build, docs, or tests. A reviewer comparing the two baseline screenshots to confirm the regression sees identical images and cannot distinguish 'initial' from 'after return'. Videos/screenshots belong as PR or issue attachments, and the repro belongs in apps/example if it is to be kept.", + "severity": "low" + }, + { + "file": "packages/react-native-bottom-tabs/ios/RepresentableView.swift", + "line": 28, + "defect": "Opening brace on its own line after a multi-line `if let ..., cond` condition. SwiftLint's default `opening_brace` rule (`ignore_multiline_statement_conditions: false`, not overridden in .swiftlint.yml) flags this; the rest of the codebase keeps the brace on the condition line (e.g. TabViewImpl.swift:119-121). Not confirmed locally because swiftlint is not installed on this machine.", + "failure_scenario": "The `swift-lint` CI job runs SwiftLint over the repo and reports a new warning on this line; if the action is ever switched to `--strict`, the job fails. Move the `{` to the end of line 27.", + "severity": "low" + }, + { + "file": "packages/react-native-bottom-tabs/ios/TabViewProvider.swift", + "line": 199, + "defect": "On the old architecture, `didUpdateReactSubviews` rebuilds `props.children` with fresh `UUID`s for every child, so SwiftUI's ForEach treats every tab as a new identity, calls `makeUIView` again, and the new FocusRestoringView starts with `lastFocusedView == nil`. The focus memory introduced by this diff is discarded on every subview change in Paper mode.", + "failure_scenario": "react-native-tvos app on the old architecture with dynamic tabs. 1) Focus Button 8 in Watching. 2) App adds or removes a tab (e.g. after login) which triggers `didUpdateReactSubviews`. 3) Press Left to open the sidebar, then Right. Focus lands on Button 0, the exact issue-501 behavior, because the wrapper holding the memory was destroyed and the replacement has nothing recorded. Keying `IdentifiablePlatformView.id` on the view (e.g. `ObjectIdentifier(view)`) instead of a per-instance UUID would preserve identity.", + "severity": "low" + } + ] +} diff --git a/evidence/issue-501/screenshots/issue-501-baseline-after-return.png b/evidence/issue-501/screenshots/issue-501-baseline-after-return.png new file mode 100644 index 00000000..689a2d38 Binary files /dev/null and b/evidence/issue-501/screenshots/issue-501-baseline-after-return.png differ diff --git a/evidence/issue-501/screenshots/issue-501-baseline-sidebar.png b/evidence/issue-501/screenshots/issue-501-baseline-sidebar.png new file mode 100644 index 00000000..30b1a464 Binary files /dev/null and b/evidence/issue-501/screenshots/issue-501-baseline-sidebar.png differ diff --git a/evidence/issue-501/screenshots/issue-501-fixed-button15.png b/evidence/issue-501/screenshots/issue-501-fixed-button15.png new file mode 100644 index 00000000..f470d112 Binary files /dev/null and b/evidence/issue-501/screenshots/issue-501-fixed-button15.png differ diff --git a/packages/react-native-bottom-tabs/ios/RepresentableView.swift b/packages/react-native-bottom-tabs/ios/RepresentableView.swift index 09adaca4..032a896f 100644 --- a/packages/react-native-bottom-tabs/ios/RepresentableView.swift +++ b/packages/react-native-bottom-tabs/ios/RepresentableView.swift @@ -1,5 +1,41 @@ import SwiftUI +#if os(tvOS) + +private final class FocusRestoringView: UIView { + private weak var lastFocusedView: UIView? + private var lastFocusedTag: Int? + + override var preferredFocusEnvironments: [UIFocusEnvironment] { + guard let lastFocusedView, + let lastFocusedTag, + lastFocusedView.tag == lastFocusedTag, + lastFocusedView.window != nil, + lastFocusedView.isDescendant(of: self), + lastFocusedView.canBecomeFocused + else { + return super.preferredFocusEnvironments + } + + return [lastFocusedView] + } + + override func didUpdateFocus( + in context: UIFocusUpdateContext, + with coordinator: UIFocusAnimationCoordinator + ) { + super.didUpdateFocus(in: context, with: coordinator) + + if let nextFocusedView = context.nextFocusedView, + nextFocusedView.isDescendant(of: self) { + lastFocusedView = nextFocusedView + lastFocusedTag = nextFocusedView.tag + } + } +} + +#endif + /** Helper used to render UIView inside of SwiftUI. Wraps each view with an additional wrapper to avoid directly managing React Native views. @@ -21,7 +57,11 @@ struct RepresentableView: PlatformViewRepresentable { #else func makeUIView(context: Context) -> PlatformView { +#if os(tvOS) + let wrapper = FocusRestoringView() +#else let wrapper = UIView() +#endif wrapper.addSubview(view) return wrapper }