From 5adc119d78d42a10919694ba895d23d7adcaab92 Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Sun, 8 Feb 2026 23:49:02 +0100 Subject: [PATCH 1/8] fix: use native iOS picker sheet with proper cancel and done behavior --- README.md | 43 +++++- apps/example/components/ActivityPicker.tsx | 123 +++++++++++------- apps/example/screens/SimpleTab.tsx | 52 ++++++++ .../ios/ReactNativeDeviceActivityModule.swift | 18 ++- .../ios/ReactNativeDeviceActivityView.swift | 36 ++++- ...actNativeDeviceActivityViewPersisted.swift | 33 ++++- .../ios/ScreenTimeActivityPicker.swift | 71 +++++++++- .../src/ReactNativeDeviceActivity.types.ts | 22 ++++ 8 files changed, 334 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index c3fe88ef..7c608e61 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,46 @@ ReactNativeDeviceActivity.revokeAuthorization(); ### Select Apps to track -For most use cases you need to get an activitySelection from the user, which is a token representing the apps the user wants to track, block or whitelist. This can be done by presenting the native view: +For most use cases you need to get an activitySelection from the user, which is a token representing the apps the user wants to track, block or whitelist. This can be done by presenting the native `DeviceActivitySelectionView`. + +#### Presentation modes + +The picker supports two presentation modes: + +**Native sheet (recommended)** -- When you set `showNavigationBar={true}`, the native side uses Apple's `.familyActivityPicker(isPresented:selection:)` view modifier to present a fully native iOS sheet with Cancel/Done in the navigation bar. The native side handles the entire sheet presentation, so you do **not** need to wrap it in a React Native `` -- just conditionally mount the view as a small anchor and it will present the sheet automatically. + +```TypeScript +// The view acts as an invisible anchor — the native side presents its own sheet. +// When the user taps Cancel or Done, onDismissRequest fires. +{pickerVisible && ( + setPickerVisible(false)} + onSelectionChange={handleSelectionChange} + familyActivitySelection={familyActivitySelection} + /> +)} +``` + +**Custom presentation (default)** -- Without `showNavigationBar`, the picker renders as an inline view with a large "Choose Activities" title. You can embed it directly in your layout or wrap it in a React Native `` for a custom sheet. This gives you full control over the presentation but does not include native Cancel/Done buttons. + +```TypeScript +import { Modal, View } from "react-native"; + + + + + + +``` + +#### Full example ```TypeScript import * as ReactNativeDeviceActivity from "react-native-device-activity"; @@ -550,7 +589,7 @@ For a complete implementation, see the [example app](https://github.com/Kingstin | Component | Props | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null
`onSelectionChange`: (event) => void
`style`: ViewStyle | Native component that renders the app selection UI | +| `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null
`onSelectionChange`: (event) => void
`headerText?`: string
`footerText?`: string
`showNavigationBar?`: boolean
`onDismissRequest?`: (event) => void
`style`: ViewStyle | Native component that renders the app selection UI. Set `showNavigationBar` to use the native `.familyActivityPicker()` sheet with Cancel/Done. | ### Hooks diff --git a/apps/example/components/ActivityPicker.tsx b/apps/example/components/ActivityPicker.tsx index 15bbf251..3fbc3f5f 100644 --- a/apps/example/components/ActivityPicker.tsx +++ b/apps/example/components/ActivityPicker.tsx @@ -1,4 +1,5 @@ -import { Pressable, Text, View, NativeSyntheticEvent } from "react-native"; +import React from "react"; +import { NativeSyntheticEvent, Pressable, StyleSheet, Text, View } from "react-native"; import { ActivitySelectionMetadata, ActivitySelectionWithMetadata, @@ -10,15 +11,7 @@ import { Modal, Portal } from "react-native-paper"; const CrashView = ({ onReload }: { onReload: () => void }) => { return ( Swift view crash - tap to reload @@ -32,6 +25,7 @@ export const ActivityPicker = ({ onSelectionChange, familyActivitySelection, onReload, + showNavigationBar = true, }: { visible: boolean; onDismiss: () => void; @@ -40,35 +34,37 @@ export const ActivityPicker = ({ ) => void; familyActivitySelection: string | undefined; onReload: () => void; + showNavigationBar?: boolean; }) => { + if (showNavigationBar) { + // Native presentation: the native side uses the + // .familyActivityPicker(isPresented:) modifier which presents its own + // sheet. We just mount a tiny anchor view — no RN Modal needed. + if (!visible) return null; + return ( + + ); + } + + // Custom modal: react-native-paper Portal + Modal with fixed height. return ( - + - {visible && ( @@ -86,6 +82,7 @@ export const ActivityPickerPersisted = ({ familyActivitySelectionId, onReload, includeEntireCategory, + showNavigationBar = true, }: { visible: boolean; onDismiss: () => void; @@ -96,35 +93,34 @@ export const ActivityPickerPersisted = ({ familyActivitySelectionId: string; onReload: () => void; includeEntireCategory?: boolean; + showNavigationBar?: boolean; }) => { + if (showNavigationBar) { + if (!visible) return null; + return ( + + ); + } + return ( - + - {visible && ( ); }; + +const styles = StyleSheet.create({ + // Invisible anchor for the native .familyActivityPicker() modifier. + nativeAnchor: { + width: 1, + height: 1, + position: "absolute", + }, + modalContainer: { + height: 600, + }, + modalContent: { + flex: 1, + height: 600, + }, + crashView: { + flex: 1, + position: "absolute", + height: 600, + width: "100%", + alignItems: "center", + justifyContent: "center", + backgroundColor: "white", + }, + picker: { + flex: 1, + height: 600, + width: "100%", + backgroundColor: "transparent", + }, +}); diff --git a/apps/example/screens/SimpleTab.tsx b/apps/example/screens/SimpleTab.tsx index 278a68f3..f5e5d5c1 100644 --- a/apps/example/screens/SimpleTab.tsx +++ b/apps/example/screens/SimpleTab.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { + NativeSyntheticEvent, ScrollView, StyleSheet, Linking, @@ -18,9 +19,11 @@ import { useAuthorizationStatus, AuthorizationStatusType, requestAuthorization, + ActivitySelectionMetadata, } from "react-native-device-activity"; import { Button, Modal, Text, Title } from "react-native-paper"; +import { ActivityPickerPersisted } from "../components/ActivityPicker"; import { CreateActivity } from "../components/CreateActivity"; const authorizationStatusMap: Record = { @@ -59,6 +62,9 @@ export function SimpleTab() { const [showCreateActivityPopup, setShowCreateActivityPopup] = useState(false); + const [pickerNative, setPickerNative] = useState(false); + const [pickerCustomModal, setPickerCustomModal] = useState(false); + const [refreshing, setRefreshing] = useState(false); useEffect(() => { @@ -140,6 +146,22 @@ export function SimpleTab() { > Create Activity + + Picker Variants + + + setPickerNative(false)} + showNavigationBar + onSelectionChange={( + event: NativeSyntheticEvent, + ) => { + console.log("native sheet selection changed", event.nativeEvent); + }} + familyActivitySelectionId="picker-native" + onReload={() => { + setPickerNative(false); + setTimeout(() => setPickerNative(true), 100); + }} + /> + setPickerCustomModal(false)} + showNavigationBar={false} + onSelectionChange={( + event: NativeSyntheticEvent, + ) => { + console.log("custom modal selection changed", event.nativeEvent); + }} + familyActivitySelectionId="picker-custom-modal" + onReload={() => { + setPickerCustomModal(false); + setTimeout(() => setPickerCustomModal(true), 100); + }} + /> ); } diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift index 3fe03eb0..efef7ea7 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift @@ -213,7 +213,8 @@ class NativeEventObserver { _: CFDictionary? ) in if let observer = observer, let name = name { - let nativeObserver = Unmanaged.fromOpaque(observer).takeUnretainedValue() + let nativeObserver = Unmanaged.fromOpaque(observer) + .takeUnretainedValue() guard let module = nativeObserver.module else { return } @@ -828,7 +829,8 @@ public class ReactNativeDeviceActivityModule: Module { // view definition: Prop, Events. View(ReactNativeDeviceActivityView.self) { Events( - "onSelectionChange" + "onSelectionChange", + "onDismissRequest" ) // Defines a setter for the `name` prop. Prop("familyActivitySelection") { (view: ReactNativeDeviceActivityView, prop: String) in @@ -844,7 +846,12 @@ public class ReactNativeDeviceActivityModule: Module { Prop("headerText") { (view: ReactNativeDeviceActivityView, prop: String?) in view.model.headerText = prop } + + Prop("showNavigationBar") { (view: ReactNativeDeviceActivityView, prop: Bool?) in + view.model.showNavigationBar = prop ?? false + } } + } } @@ -854,7 +861,8 @@ public class ReactNativeDeviceActivityViewPersistedModule: Module { Name("ReactNativeDeviceActivityViewPersistedModule") View(ReactNativeDeviceActivityViewPersisted.self) { Events( - "onSelectionChange" + "onSelectionChange", + "onDismissRequest" ) // Defines a setter for the `name` prop. Prop("familyActivitySelectionId") { @@ -894,6 +902,10 @@ public class ReactNativeDeviceActivityViewPersistedModule: Module { Prop("headerText") { (view: ReactNativeDeviceActivityViewPersisted, prop: String?) in view.model.headerText = prop } + + Prop("showNavigationBar") { (view: ReactNativeDeviceActivityViewPersisted, prop: Bool?) in + view.model.showNavigationBar = prop ?? false + } } } } diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift index 675dfcf2..0605790f 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift @@ -24,13 +24,15 @@ class ReactNativeDeviceActivityView: ExpoView { clipsToBounds = true backgroundColor = .clear - isUserInteractionEnabled = false contentView.view.backgroundColor = .clear - contentView.view.isUserInteractionEnabled = false self.addSubview(contentView.view) + model.onDismissRequest = { [weak self] in + self?.onDismissRequest([:]) + } + model.$activitySelection.debounce(for: .seconds(0.1), scheduler: RunLoop.main).sink { selection in if selection != self.previousSelection { @@ -45,7 +47,37 @@ class ReactNativeDeviceActivityView: ExpoView { contentView.view.frame = bounds } + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + // Establish a proper UIKit parent–child VC relationship so that + // SwiftUI presentation modifiers (like .familyActivityPicker) can + // walk the VC hierarchy and present sheets. + if contentView.parent == nil, let parentVC = parentViewController { + parentVC.addChild(contentView) + contentView.didMove(toParent: parentVC) + } + } else { + if contentView.parent != nil { + contentView.willMove(toParent: nil) + contentView.removeFromParent() + } + } + } + + private var parentViewController: UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let vc = next as? UIViewController { + return vc + } + responder = next + } + return nil + } + let onSelectionChange = EventDispatcher() + let onDismissRequest = EventDispatcher() var previousSelection: FamilyActivitySelection? diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift index ea3cde08..89b3999e 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift @@ -24,13 +24,15 @@ class ReactNativeDeviceActivityViewPersisted: ExpoView { clipsToBounds = true backgroundColor = .clear - isUserInteractionEnabled = false contentView.view.backgroundColor = .clear - contentView.view.isUserInteractionEnabled = false self.addSubview(contentView.view) + model.onDismissRequest = { [weak self] in + self?.onDismissRequest([:]) + } + model.$activitySelection.debounce(for: .seconds(0.1), scheduler: RunLoop.main).sink { selection in if selection != self.previousSelection { @@ -45,7 +47,34 @@ class ReactNativeDeviceActivityViewPersisted: ExpoView { contentView.view.frame = bounds } + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + if contentView.parent == nil, let parentVC = parentViewController { + parentVC.addChild(contentView) + contentView.didMove(toParent: parentVC) + } + } else { + if contentView.parent != nil { + contentView.willMove(toParent: nil) + contentView.removeFromParent() + } + } + } + + private var parentViewController: UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let vc = next as? UIViewController { + return vc + } + responder = next + } + return nil + } + let onSelectionChange = EventDispatcher() + let onDismissRequest = EventDispatcher() var previousSelection: FamilyActivitySelection? diff --git a/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift b/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift index b175a1b4..11da896d 100644 --- a/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift +++ b/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift @@ -23,6 +23,10 @@ class ScreenTimeSelectAppsModel: ObservableObject { @Published public var includeEntireCategory: Bool? + @Published public var showNavigationBar: Bool = false + + var onDismissRequest: (() -> Void)? + init() {} } @@ -30,21 +34,74 @@ class ScreenTimeSelectAppsModel: ObservableObject { struct ActivityPicker: View { @ObservedObject var model: ScreenTimeSelectAppsModel + /// Local state used by the `.familyActivityPicker` modifier to drive + /// its native sheet presentation. + @State private var isPickerPresented = false + + private var resolvedHeaderText: String? { + let trimmed = model.headerText?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + } + + private var resolvedFooterText: String? { + let trimmed = model.footerText?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + } + var body: some View { - if #available(iOS 16.0, *) { + if model.showNavigationBar { + // Use the `.familyActivityPicker(isPresented:selection:)` **modifier** + // instead of the inline `FamilyActivityPicker` view. The modifier + // presents the picker as a native sheet with Cancel/Done in the nav bar. + nativeSheetPresentation + } else { + pickerContent + } + } + + // MARK: - Native sheet (modifier-based) presentation + + @ViewBuilder + private var nativeSheetPresentation: some View { + if #available(iOS 16.0, *), resolvedHeaderText != nil || resolvedFooterText != nil { + Color.clear + .familyActivityPicker( + headerText: resolvedHeaderText, + footerText: resolvedFooterText, + isPresented: $isPickerPresented, + selection: $model.activitySelection + ) + .onAppear { isPickerPresented = true } + .onChange(of: isPickerPresented) { presented in + if !presented { model.onDismissRequest?() } + } + } else { + Color.clear + .familyActivityPicker( + isPresented: $isPickerPresented, + selection: $model.activitySelection + ) + .onAppear { isPickerPresented = true } + .onChange(of: isPickerPresented) { presented in + if !presented { model.onDismissRequest?() } + } + } + } + + // MARK: - Inline (embedded) picker + + @ViewBuilder + private var pickerContent: some View { + if #available(iOS 16.0, *), resolvedHeaderText != nil || resolvedFooterText != nil { FamilyActivityPicker( - headerText: model.headerText, - footerText: model.footerText, + headerText: resolvedHeaderText, + footerText: resolvedFooterText, selection: $model.activitySelection ) - .allowsHitTesting(false) - .background(Color.clear) } else { FamilyActivityPicker( selection: $model.activitySelection ) - .allowsHitTesting(false) - .background(Color.clear) } } } diff --git a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts index 1fae7904..eeeae2bb 100644 --- a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts +++ b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts @@ -44,6 +44,17 @@ export type DeviceActivitySelectionViewProps = PropsWithChildren<{ familyActivitySelection?: string | null; headerText?: string | null; footerText?: string | null; + /** + * When true, wraps the picker in a NavigationView with Cancel/Done toolbar + * buttons, matching the native `.familyActivityPicker()` presentation style. + * Use together with `onDismissRequest` to handle dismissal. + */ + showNavigationBar?: boolean; + /** + * Called when the user taps Cancel or Done in the navigation bar. + * Only fires when `showNavigationBar` is true. + */ + onDismissRequest?: (event: NativeSyntheticEvent>) => void; }>; export type DeviceActivitySelectionViewPersistedProps = PropsWithChildren<{ @@ -59,6 +70,17 @@ export type DeviceActivitySelectionViewPersistedProps = PropsWithChildren<{ * @link https://developer.apple.com/documentation/familycontrols/familyactivityselection/includeentirecategory */ includeEntireCategory?: boolean; + /** + * When true, wraps the picker in a NavigationView with Cancel/Done toolbar + * buttons, matching the native `.familyActivityPicker()` presentation style. + * Use together with `onDismissRequest` to handle dismissal. + */ + showNavigationBar?: boolean; + /** + * Called when the user taps Cancel or Done in the navigation bar. + * Only fires when `showNavigationBar` is true. + */ + onDismissRequest?: (event: NativeSyntheticEvent>) => void; }>; /** From cf84e0e141fa91f6b35503dfc3ae6614caf1d25c Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Sun, 8 Feb 2026 23:49:02 +0100 Subject: [PATCH 2/8] fix: manually switch bottom of native sheet from systemBackground to systemGroupedBackground --- .../ios/ReactNativeDeviceActivityModule.swift | 13 +++- .../ios/ScreenTimeActivityPicker.swift | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift index efef7ea7..c1ccf0fc 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityModule.swift @@ -848,7 +848,13 @@ public class ReactNativeDeviceActivityModule: Module { } Prop("showNavigationBar") { (view: ReactNativeDeviceActivityView, prop: Bool?) in - view.model.showNavigationBar = prop ?? false + let enabled = prop ?? false + view.model.showNavigationBar = enabled + // When using the native .familyActivityPicker() modifier, set the + // hosting controller background so the presented sheet inherits it + // instead of falling through to the window's white default. + view.contentView.view.backgroundColor = enabled ? .systemGroupedBackground : .clear + view.backgroundColor = enabled ? .systemGroupedBackground : .clear } } @@ -904,7 +910,10 @@ public class ReactNativeDeviceActivityViewPersistedModule: Module { } Prop("showNavigationBar") { (view: ReactNativeDeviceActivityViewPersisted, prop: Bool?) in - view.model.showNavigationBar = prop ?? false + let enabled = prop ?? false + view.model.showNavigationBar = enabled + view.contentView.view.backgroundColor = enabled ? .systemGroupedBackground : .clear + view.backgroundColor = enabled ? .systemGroupedBackground : .clear } } } diff --git a/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift b/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift index 11da896d..7a82d7e9 100644 --- a/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift +++ b/packages/react-native-device-activity/ios/ScreenTimeActivityPicker.swift @@ -75,6 +75,7 @@ struct ActivityPicker: View { .onChange(of: isPickerPresented) { presented in if !presented { model.onDismissRequest?() } } + .background(PresentedSheetBackgroundFixer()) } else { Color.clear .familyActivityPicker( @@ -85,6 +86,7 @@ struct ActivityPicker: View { .onChange(of: isPickerPresented) { presented in if !presented { model.onDismissRequest?() } } + .background(PresentedSheetBackgroundFixer()) } } @@ -105,3 +107,66 @@ struct ActivityPicker: View { } } } + +// MARK: - Sheet background fix + +/// Finds the presented picker sheet's view hierarchy and sets the background +/// to `systemGroupedBackground` so the empty area below the list matches +/// the rest of the sheet. Uses a VC representable that observes when our +/// hosting controller presents a child. +@available(iOS 15.0, *) +struct PresentedSheetBackgroundFixer: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> SheetBackgroundFixerController { + SheetBackgroundFixerController() + } + + func updateUIViewController(_ uiViewController: SheetBackgroundFixerController, context: Context) {} +} + +@available(iOS 15.0, *) +class SheetBackgroundFixerController: UIViewController { + private var observation: NSKeyValueObservation? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + startObserving() + } + + private func startObserving() { + // Walk up to find the VC that will present the picker sheet. + var candidate: UIViewController? = self + while let c = candidate { + // Observe `presentedViewController` so we catch it the moment the + // picker sheet appears. + observation = c.observe( + \.presentedViewController, options: [.new] + ) { [weak self] vc, _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self?.fixPresentedBackground(from: vc) + } + } + // Also try immediately in case it's already presented. + fixPresentedBackground(from: c) + if c.presentedViewController != nil || c.parent == nil { break } + candidate = c.parent + } + } + + private func fixPresentedBackground(from vc: UIViewController) { + guard let presented = vc.presentedViewController else { return } + applySystemBackground(to: presented.view) + for child in presented.children { + applySystemBackground(to: child.view) + } + } + + private func applySystemBackground(to view: UIView) { + view.backgroundColor = .systemGroupedBackground + // The picker nests views; walk a few levels deep. + for sub in view.subviews { + if sub.backgroundColor == .white || sub.backgroundColor == .systemBackground { + sub.backgroundColor = .systemGroupedBackground + } + } + } +} From c7219a30690529152ab75f05efeffe242e5bc28d Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Mon, 9 Feb 2026 00:52:38 +0100 Subject: [PATCH 3/8] docs: document persisted selection picker native-sheet props --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7c608e61..72ac4431 100644 --- a/README.md +++ b/README.md @@ -590,6 +590,7 @@ For a complete implementation, see the [example app](https://github.com/Kingstin | Component | Props | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null
`onSelectionChange`: (event) => void
`headerText?`: string
`footerText?`: string
`showNavigationBar?`: boolean
`onDismissRequest?`: (event) => void
`style`: ViewStyle | Native component that renders the app selection UI. Set `showNavigationBar` to use the native `.familyActivityPicker()` sheet with Cancel/Done. | +| `DeviceActivitySelectionViewPersisted` | `familyActivitySelectionId`: string
`onSelectionChange`: (event) => void
`includeEntireCategory?`: boolean
`headerText?`: string
`footerText?`: string
`showNavigationBar?`: boolean
`onDismissRequest?`: (event) => void
`style`: ViewStyle | Persisted variant of the selection picker keyed by `familyActivitySelectionId`. Supports the same native sheet presentation with `showNavigationBar`. | ### Hooks From 3846542355f02ac3e19452bf58da229e4455980d Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Mon, 9 Feb 2026 10:42:16 +0100 Subject: [PATCH 4/8] feat: split native picker sheet into dedicated components --- README.md | 46 +++++++++++-------- apps/example/components/ActivityPicker.tsx | 8 ++-- .../DeviceActivitySelectionSheetView.ios.tsx | 21 +++++++++ .../src/DeviceActivitySelectionSheetView.tsx | 11 +++++ ...ctivitySelectionSheetViewPersisted.ios.tsx | 22 +++++++++ ...iceActivitySelectionSheetViewPersisted.tsx | 11 +++++ .../src/ReactNativeDeviceActivity.types.ts | 33 ++++++------- .../src/index.test.ts | 8 ++++ .../react-native-device-activity/src/index.ts | 13 +++++- 9 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.ios.tsx create mode 100644 packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.tsx create mode 100644 packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.ios.tsx create mode 100644 packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.tsx diff --git a/README.md b/README.md index 72ac4431..54ccaca1 100644 --- a/README.md +++ b/README.md @@ -203,19 +203,18 @@ ReactNativeDeviceActivity.revokeAuthorization(); For most use cases you need to get an activitySelection from the user, which is a token representing the apps the user wants to track, block or whitelist. This can be done by presenting the native `DeviceActivitySelectionView`. -#### Presentation modes +#### Presentation options -The picker supports two presentation modes: +The picker now has dedicated components for each presentation style: -**Native sheet (recommended)** -- When you set `showNavigationBar={true}`, the native side uses Apple's `.familyActivityPicker(isPresented:selection:)` view modifier to present a fully native iOS sheet with Cancel/Done in the navigation bar. The native side handles the entire sheet presentation, so you do **not** need to wrap it in a React Native `` -- just conditionally mount the view as a small anchor and it will present the sheet automatically. +**Native sheet** -- `DeviceActivitySelectionSheetView` (and persisted variant) uses Apple's `.familyActivityPicker(isPresented:selection:)` flow with native Cancel/Done controls. ```TypeScript -// The view acts as an invisible anchor — the native side presents its own sheet. -// When the user taps Cancel or Done, onDismissRequest fires. +// The sheet view acts as an invisible anchor. +// The native side presents the iOS sheet and fires onDismissRequest on Cancel/Done. {pickerVisible && ( - setPickerVisible(false)} onSelectionChange={handleSelectionChange} familyActivitySelection={familyActivitySelection} @@ -223,21 +222,26 @@ The picker supports two presentation modes: )} ``` -**Custom presentation (default)** -- Without `showNavigationBar`, the picker renders as an inline view with a large "Choose Activities" title. You can embed it directly in your layout or wrap it in a React Native `` for a custom sheet. This gives you full control over the presentation but does not include native Cancel/Done buttons. +**Custom presentation (fallback/customizable)** -- `DeviceActivitySelectionView` (and persisted variant) renders inline. You can embed it directly in your layout or wrap it in a React Native `` for a custom sheet. ```TypeScript import { Modal, View } from "react-native"; - - - - - + + + + + ``` #### Full example @@ -589,8 +593,10 @@ For a complete implementation, see the [example app](https://github.com/Kingstin | Component | Props | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null
`onSelectionChange`: (event) => void
`headerText?`: string
`footerText?`: string
`showNavigationBar?`: boolean
`onDismissRequest?`: (event) => void
`style`: ViewStyle | Native component that renders the app selection UI. Set `showNavigationBar` to use the native `.familyActivityPicker()` sheet with Cancel/Done. | -| `DeviceActivitySelectionViewPersisted` | `familyActivitySelectionId`: string
`onSelectionChange`: (event) => void
`includeEntireCategory?`: boolean
`headerText?`: string
`footerText?`: string
`showNavigationBar?`: boolean
`onDismissRequest?`: (event) => void
`style`: ViewStyle | Persisted variant of the selection picker keyed by `familyActivitySelectionId`. Supports the same native sheet presentation with `showNavigationBar`. | +| `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null
`onSelectionChange`: (event) => void
`headerText?`: string
`footerText?`: string
`style`: ViewStyle | Inline/customizable native picker view. Useful when you want to control modal/presentation yourself and provide a fallback UI. | +| `DeviceActivitySelectionViewPersisted` | `familyActivitySelectionId`: string
`onSelectionChange`: (event) => void
`includeEntireCategory?`: boolean
`headerText?`: string
`footerText?`: string
`style`: ViewStyle | Persisted inline/customizable picker keyed by `familyActivitySelectionId`. | +| `DeviceActivitySelectionSheetView` | `familyActivitySelection`: string \| null
`onSelectionChange`: (event) => void
`headerText?`: string
`footerText?`: string
`onDismissRequest?`: (event) => void
`style`: ViewStyle | Dedicated native iOS sheet picker with Cancel/Done controls. | +| `DeviceActivitySelectionSheetViewPersisted` | `familyActivitySelectionId`: string
`onSelectionChange`: (event) => void
`includeEntireCategory?`: boolean
`headerText?`: string
`footerText?`: string
`onDismissRequest?`: (event) => void
`style`: ViewStyle | Persisted dedicated native iOS sheet picker keyed by `familyActivitySelectionId`. | ### Hooks diff --git a/apps/example/components/ActivityPicker.tsx b/apps/example/components/ActivityPicker.tsx index 3fbc3f5f..c479354b 100644 --- a/apps/example/components/ActivityPicker.tsx +++ b/apps/example/components/ActivityPicker.tsx @@ -2,6 +2,8 @@ import React from "react"; import { NativeSyntheticEvent, Pressable, StyleSheet, Text, View } from "react-native"; import { ActivitySelectionMetadata, + DeviceActivitySelectionSheetView, + DeviceActivitySelectionSheetViewPersisted, ActivitySelectionWithMetadata, DeviceActivitySelectionView, DeviceActivitySelectionViewPersisted, @@ -42,9 +44,8 @@ export const ActivityPicker = ({ // sheet. We just mount a tiny anchor view — no RN Modal needed. if (!visible) return null; return ( - = + requireNativeViewManager("ReactNativeDeviceActivity"); + +export default function DeviceActivitySelectionSheetView( + props: DeviceActivitySelectionSheetViewProps, +) { + return ; +} diff --git a/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.tsx b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.tsx new file mode 100644 index 00000000..694d8b41 --- /dev/null +++ b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetView.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; +import { View } from "react-native"; + +import { DeviceActivitySelectionSheetViewProps } from "./ReactNativeDeviceActivity.types"; + +export default function DeviceActivitySelectionSheetView({ + style, + children, +}: DeviceActivitySelectionSheetViewProps) { + return {children}; +} diff --git a/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.ios.tsx b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.ios.tsx new file mode 100644 index 00000000..5a4c0947 --- /dev/null +++ b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.ios.tsx @@ -0,0 +1,22 @@ +import { requireNativeViewManager } from "expo-modules-core"; +import * as React from "react"; + +import { + DeviceActivitySelectionSheetViewPersistedProps, + DeviceActivitySelectionViewPersistedProps, +} from "./ReactNativeDeviceActivity.types"; + +type NativeSheetViewPersistedProps = DeviceActivitySelectionViewPersistedProps & { + showNavigationBar: boolean; + onDismissRequest?: + DeviceActivitySelectionSheetViewPersistedProps["onDismissRequest"]; +}; + +const NativeView: React.ComponentType = + requireNativeViewManager("ReactNativeDeviceActivityViewPersistedModule"); + +export default function DeviceActivitySelectionSheetViewPersisted( + props: DeviceActivitySelectionSheetViewPersistedProps, +) { + return ; +} diff --git a/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.tsx b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.tsx new file mode 100644 index 00000000..6d560269 --- /dev/null +++ b/packages/react-native-device-activity/src/DeviceActivitySelectionSheetViewPersisted.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; +import { View } from "react-native"; + +import { DeviceActivitySelectionSheetViewPersistedProps } from "./ReactNativeDeviceActivity.types"; + +export default function DeviceActivitySelectionSheetViewPersisted({ + style, + children, +}: DeviceActivitySelectionSheetViewPersistedProps) { + return {children}; +} diff --git a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts index eeeae2bb..5ae9bcf4 100644 --- a/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts +++ b/packages/react-native-device-activity/src/ReactNativeDeviceActivity.types.ts @@ -44,18 +44,14 @@ export type DeviceActivitySelectionViewProps = PropsWithChildren<{ familyActivitySelection?: string | null; headerText?: string | null; footerText?: string | null; +}>; + +export type DeviceActivitySelectionSheetViewProps = DeviceActivitySelectionViewProps & { /** - * When true, wraps the picker in a NavigationView with Cancel/Done toolbar - * buttons, matching the native `.familyActivityPicker()` presentation style. - * Use together with `onDismissRequest` to handle dismissal. - */ - showNavigationBar?: boolean; - /** - * Called when the user taps Cancel or Done in the navigation bar. - * Only fires when `showNavigationBar` is true. + * Called when the user taps Cancel or Done in the native sheet navigation bar. */ onDismissRequest?: (event: NativeSyntheticEvent>) => void; -}>; +}; export type DeviceActivitySelectionViewPersistedProps = PropsWithChildren<{ style?: StyleProp; @@ -70,19 +66,16 @@ export type DeviceActivitySelectionViewPersistedProps = PropsWithChildren<{ * @link https://developer.apple.com/documentation/familycontrols/familyactivityselection/includeentirecategory */ includeEntireCategory?: boolean; - /** - * When true, wraps the picker in a NavigationView with Cancel/Done toolbar - * buttons, matching the native `.familyActivityPicker()` presentation style. - * Use together with `onDismissRequest` to handle dismissal. - */ - showNavigationBar?: boolean; - /** - * Called when the user taps Cancel or Done in the navigation bar. - * Only fires when `showNavigationBar` is true. - */ - onDismissRequest?: (event: NativeSyntheticEvent>) => void; }>; +export type DeviceActivitySelectionSheetViewPersistedProps = + DeviceActivitySelectionViewPersistedProps & { + /** + * Called when the user taps Cancel or Done in the native sheet navigation bar. + */ + onDismissRequest?: (event: NativeSyntheticEvent>) => void; + }; + /** * @link https://developer.apple.com/documentation/foundation/datecomponents */ diff --git a/packages/react-native-device-activity/src/index.test.ts b/packages/react-native-device-activity/src/index.test.ts index db8d511c..b32e1b29 100644 --- a/packages/react-native-device-activity/src/index.test.ts +++ b/packages/react-native-device-activity/src/index.test.ts @@ -1,6 +1,14 @@ // todo: skipping for now describe("test", () => { + test("Should export sheet picker views", () => { + jest.isolateModules(() => { + const module = require("./"); + expect(module.DeviceActivitySelectionSheetView).toBeDefined(); + expect(module.DeviceActivitySelectionSheetViewPersisted).toBeDefined(); + }); + }); + test("Should call stopMonitoring", () => { const mockStopMonitoring = jest.fn(); jest.mock("./ReactNativeDeviceActivityModule", () => ({ diff --git a/packages/react-native-device-activity/src/index.ts b/packages/react-native-device-activity/src/index.ts index 938f02da..0e01eaaa 100644 --- a/packages/react-native-device-activity/src/index.ts +++ b/packages/react-native-device-activity/src/index.ts @@ -2,6 +2,8 @@ import { EventEmitter, EventSubscription } from "expo-modules-core"; import { useCallback, useEffect, useState } from "react"; import { Platform } from "react-native"; +import DeviceActivitySelectionSheetView from "./DeviceActivitySelectionSheetView"; +import DeviceActivitySelectionSheetViewPersisted from "./DeviceActivitySelectionSheetViewPersisted"; import DeviceActivitySelectionView from "./DeviceActivitySelectionView"; import DeviceActivitySelectionViewPersisted from "./DeviceActivitySelectionViewPersisted"; import { @@ -18,6 +20,8 @@ import { DeviceActivityEventRaw, DeviceActivityMonitorEventPayload, DeviceActivitySchedule, + DeviceActivitySelectionSheetViewPersistedProps, + DeviceActivitySelectionSheetViewProps, DeviceActivitySelectionViewPersistedProps, DeviceActivitySelectionViewProps, EventListenerMap, @@ -649,9 +653,16 @@ export function isAvailable(): boolean { ); } -export { DeviceActivitySelectionView, DeviceActivitySelectionViewPersisted }; +export { + DeviceActivitySelectionSheetView, + DeviceActivitySelectionSheetViewPersisted, + DeviceActivitySelectionView, + DeviceActivitySelectionViewPersisted, +}; export type { + DeviceActivitySelectionSheetViewProps as ReactNativeDeviceActivitySheetViewProps, + DeviceActivitySelectionSheetViewPersistedProps as ReactNativeDeviceActivitySheetViewPersistedProps, DeviceActivitySelectionViewProps as ReactNativeDeviceActivityViewProps, DeviceActivitySelectionViewPersistedProps as ReactNativeDeviceActivityViewPersistedProps, }; From a65d6e396925fd427e83391bb56b01d549b3733e Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Mon, 9 Feb 2026 11:07:14 +0100 Subject: [PATCH 5/8] docs: clarify picker variant choices and persisted selection usage --- README.md | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 54ccaca1..b67d4c35 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,9 @@ For most use cases you need to get an activitySelection from the user, which is The picker now has dedicated components for each presentation style: +`*SelectionView` components take a raw `familyActivitySelection` token. +`*SelectionViewPersisted` components take a `familyActivitySelectionId` and persist/read the token on the native side by ID. + **Native sheet** -- `DeviceActivitySelectionSheetView` (and persisted variant) uses Apple's `.familyActivityPicker(isPresented:selection:)` flow with native Cancel/Done controls. ```TypeScript @@ -227,23 +230,29 @@ The picker now has dedicated components for each presentation style: ```TypeScript import { Modal, View } from "react-native"; - - - - - + + + + + ``` +#### Which one should I use? + +- Use `DeviceActivitySelectionSheetView` for a native iOS sheet UX (system Cancel/Done). +- Use `DeviceActivitySelectionView` when you need full control over presentation and a custom crash fallback UI. +- Use the persisted variants when you want to store/reuse selections across screens/sessions or avoid passing very large selection tokens through JS. + #### Full example ```TypeScript From 8e40412e475e5c1fb7a45dcb1adbf26af8e2109e Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Mon, 9 Feb 2026 11:13:04 +0100 Subject: [PATCH 6/8] fix(example): restore Create Activity picker interaction Fix a pre-existing bug in the example app where tapping "Select apps" inside the Create Activity modal did not open the picker flow. --- apps/example/components/CreateActivity.tsx | 47 ++++++++++++---------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/example/components/CreateActivity.tsx b/apps/example/components/CreateActivity.tsx index a91e70ec..8488c3b6 100644 --- a/apps/example/components/CreateActivity.tsx +++ b/apps/example/components/CreateActivity.tsx @@ -1,6 +1,6 @@ import { requestPermissionsAsync } from "expo-notifications"; import { useCallback, useState } from "react"; -import { NativeSyntheticEvent, View } from "react-native"; +import { NativeSyntheticEvent, Pressable, View } from "react-native"; import * as ReactNativeDeviceActivity from "react-native-device-activity"; import { DeviceActivityEvent, @@ -8,6 +8,8 @@ import { } from "react-native-device-activity/src/ReactNativeDeviceActivity.types"; import { Button, Text, TextInput, Title, useTheme } from "react-native-paper"; +import { ActivityPicker } from "./ActivityPicker"; + const trackEveryXMinutes = 10; const potentialMaxEvents = Math.floor((60 * 24) / trackEveryXMinutes); @@ -175,6 +177,7 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => { ); const [activityName, setActivityName] = useState(""); + const [showSelectionView, setShowSelectionView] = useState(false); return ( @@ -187,33 +190,19 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => { marginVertical: 10, }} > - setShowSelectionView(true)} > - - Select apps - - + Select apps +
{familyActivitySelectionResult && familyActivitySelectionResult?.categoryCount < 13 @@ -223,6 +212,20 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => { : "Nothing selected"} + setShowSelectionView(false)} + onSelectionChange={onSelectionChange} + familyActivitySelection={ + familyActivitySelectionResult?.familyActivitySelection ?? undefined + } + onReload={() => { + setShowSelectionView(false); + setTimeout(() => { + setShowSelectionView(true); + }, 100); + }} + /> setActivityName(text)} From c242d61ce93beb5c5e45def56ae694223da41fff Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Mon, 9 Feb 2026 11:17:21 +0100 Subject: [PATCH 7/8] fix(example): rename picker view labels for clearer behavior Align example UI wording with the split component model (Sheet View vs Selection View) to avoid implying the custom path is deprecated. --- apps/example/screens/SimpleTab.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/example/screens/SimpleTab.tsx b/apps/example/screens/SimpleTab.tsx index f5e5d5c1..82a5616d 100644 --- a/apps/example/screens/SimpleTab.tsx +++ b/apps/example/screens/SimpleTab.tsx @@ -147,20 +147,20 @@ export function SimpleTab() { Create Activity - Picker Variants + Picker Views , ) => { - console.log("native sheet selection changed", event.nativeEvent); + console.log("sheet view selection changed", event.nativeEvent); }} familyActivitySelectionId="picker-native" onReload={() => { @@ -197,7 +197,7 @@ export function SimpleTab() { onSelectionChange={( event: NativeSyntheticEvent, ) => { - console.log("custom modal selection changed", event.nativeEvent); + console.log("selection view changed", event.nativeEvent); }} familyActivitySelectionId="picker-custom-modal" onReload={() => { From 8cff8577cfab223f754d5d72c8254a9676b3a987 Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Mon, 9 Feb 2026 14:33:42 +0100 Subject: [PATCH 8/8] fix(ios): keep picker host views non-interactive for fallback overlays --- .../ios/ReactNativeDeviceActivityView.swift | 2 ++ .../ios/ReactNativeDeviceActivityViewPersisted.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift index 0605790f..897a3221 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityView.swift @@ -24,8 +24,10 @@ class ReactNativeDeviceActivityView: ExpoView { clipsToBounds = true backgroundColor = .clear + isUserInteractionEnabled = false contentView.view.backgroundColor = .clear + contentView.view.isUserInteractionEnabled = false self.addSubview(contentView.view) diff --git a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift index 89b3999e..f37a2ce0 100644 --- a/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift +++ b/packages/react-native-device-activity/ios/ReactNativeDeviceActivityViewPersisted.swift @@ -24,8 +24,10 @@ class ReactNativeDeviceActivityViewPersisted: ExpoView { clipsToBounds = true backgroundColor = .clear + isUserInteractionEnabled = false contentView.view.backgroundColor = .clear + contentView.view.isUserInteractionEnabled = false self.addSubview(contentView.view)