Skip to content

Commit 5dee761

Browse files
authored
fix: switch activity picker to native iOS sheet behavior (#84)
* fix: use native iOS picker sheet with proper cancel and done behavior * fix: manually switch bottom of native sheet from systemBackground to systemGroupedBackground * docs: document persisted selection picker native-sheet props * feat: split native picker sheet into dedicated components * docs: clarify picker variant choices and persisted selection usage * 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. * 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. * fix(ios): keep picker host views non-interactive for fallback overlays
1 parent be04421 commit 5dee761

15 files changed

Lines changed: 527 additions & 83 deletions

README.md

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,59 @@ ReactNativeDeviceActivity.revokeAuthorization();
201201

202202
### Select Apps to track
203203

204-
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:
204+
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`.
205+
206+
#### Presentation options
207+
208+
The picker now has dedicated components for each presentation style:
209+
210+
`*SelectionView` components take a raw `familyActivitySelection` token.
211+
`*SelectionViewPersisted` components take a `familyActivitySelectionId` and persist/read the token on the native side by ID.
212+
213+
**Native sheet** -- `DeviceActivitySelectionSheetView` (and persisted variant) uses Apple's `.familyActivityPicker(isPresented:selection:)` flow with native Cancel/Done controls.
214+
215+
```TypeScript
216+
// The sheet view acts as an invisible anchor.
217+
// The native side presents the iOS sheet and fires onDismissRequest on Cancel/Done.
218+
{pickerVisible && (
219+
<DeviceActivitySelectionSheetView
220+
style={{ width: 1, height: 1, position: "absolute" }}
221+
onDismissRequest={() => setPickerVisible(false)}
222+
onSelectionChange={handleSelectionChange}
223+
familyActivitySelection={familyActivitySelection}
224+
/>
225+
)}
226+
```
227+
228+
**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 `<Modal>` for a custom sheet.
229+
230+
```TypeScript
231+
import { Modal, View } from "react-native";
232+
233+
<Modal
234+
visible={visible}
235+
animationType="slide"
236+
presentationStyle="pageSheet"
237+
onRequestClose={onDismiss}
238+
onDismiss={onDismiss}
239+
>
240+
<View style={{ flex: 1 }}>
241+
<DeviceActivitySelectionView
242+
style={{ flex: 1, width: "100%" }}
243+
onSelectionChange={handleSelectionChange}
244+
familyActivitySelection={familyActivitySelection}
245+
/>
246+
</View>
247+
</Modal>
248+
```
249+
250+
#### Which one should I use?
251+
252+
- Use `DeviceActivitySelectionSheetView` for a native iOS sheet UX (system Cancel/Done).
253+
- Use `DeviceActivitySelectionView` when you need full control over presentation and a custom crash fallback UI.
254+
- Use the persisted variants when you want to store/reuse selections across screens/sessions or avoid passing very large selection tokens through JS.
255+
256+
#### Full example
205257

206258
```TypeScript
207259
import * as ReactNativeDeviceActivity from "react-native-device-activity";
@@ -550,7 +602,10 @@ For a complete implementation, see the [example app](https://github.com/Kingstin
550602

551603
| Component | Props | Description |
552604
| ----------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
553-
| `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null<br>`onSelectionChange`: (event) => void<br>`style`: ViewStyle | Native component that renders the app selection UI |
605+
| `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null<br>`onSelectionChange`: (event) => void<br>`headerText?`: string<br>`footerText?`: string<br>`style`: ViewStyle | Inline/customizable native picker view. Useful when you want to control modal/presentation yourself and provide a fallback UI. |
606+
| `DeviceActivitySelectionViewPersisted` | `familyActivitySelectionId`: string<br>`onSelectionChange`: (event) => void<br>`includeEntireCategory?`: boolean<br>`headerText?`: string<br>`footerText?`: string<br>`style`: ViewStyle | Persisted inline/customizable picker keyed by `familyActivitySelectionId`. |
607+
| `DeviceActivitySelectionSheetView` | `familyActivitySelection`: string \| null<br>`onSelectionChange`: (event) => void<br>`headerText?`: string<br>`footerText?`: string<br>`onDismissRequest?`: (event) => void<br>`style`: ViewStyle | Dedicated native iOS sheet picker with Cancel/Done controls. |
608+
| `DeviceActivitySelectionSheetViewPersisted` | `familyActivitySelectionId`: string<br>`onSelectionChange`: (event) => void<br>`includeEntireCategory?`: boolean<br>`headerText?`: string<br>`footerText?`: string<br>`onDismissRequest?`: (event) => void<br>`style`: ViewStyle | Persisted dedicated native iOS sheet picker keyed by `familyActivitySelectionId`. |
554609

555610
### Hooks
556611

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { Pressable, Text, View, NativeSyntheticEvent } from "react-native";
1+
import React from "react";
2+
import { NativeSyntheticEvent, Pressable, StyleSheet, Text, View } from "react-native";
23
import {
34
ActivitySelectionMetadata,
5+
DeviceActivitySelectionSheetView,
6+
DeviceActivitySelectionSheetViewPersisted,
47
ActivitySelectionWithMetadata,
58
DeviceActivitySelectionView,
69
DeviceActivitySelectionViewPersisted,
@@ -10,15 +13,7 @@ import { Modal, Portal } from "react-native-paper";
1013
const CrashView = ({ onReload }: { onReload: () => void }) => {
1114
return (
1215
<Pressable
13-
style={{
14-
flex: 1,
15-
position: "absolute",
16-
height: 600,
17-
width: "100%",
18-
alignItems: "center",
19-
justifyContent: "center",
20-
backgroundColor: "white",
21-
}}
16+
style={styles.crashView}
2217
onPress={onReload}
2318
>
2419
<Text>Swift view crash - tap to reload</Text>
@@ -32,6 +27,7 @@ export const ActivityPicker = ({
3227
onSelectionChange,
3328
familyActivitySelection,
3429
onReload,
30+
showNavigationBar = true,
3531
}: {
3632
visible: boolean;
3733
onDismiss: () => void;
@@ -40,35 +36,36 @@ export const ActivityPicker = ({
4036
) => void;
4137
familyActivitySelection: string | undefined;
4238
onReload: () => void;
39+
showNavigationBar?: boolean;
4340
}) => {
41+
if (showNavigationBar) {
42+
// Native presentation: the native side uses the
43+
// .familyActivityPicker(isPresented:) modifier which presents its own
44+
// sheet. We just mount a tiny anchor view — no RN Modal needed.
45+
if (!visible) return null;
46+
return (
47+
<DeviceActivitySelectionSheetView
48+
style={styles.nativeAnchor}
49+
onDismissRequest={onDismiss}
50+
onSelectionChange={onSelectionChange}
51+
familyActivitySelection={familyActivitySelection}
52+
/>
53+
);
54+
}
55+
56+
// Custom modal: react-native-paper Portal + Modal with fixed height.
4457
return (
4558
<Portal>
4659
<Modal
4760
visible={visible}
4861
onDismiss={onDismiss}
49-
contentContainerStyle={{
50-
height: 600,
51-
}}
62+
contentContainerStyle={styles.modalContainer}
5263
>
53-
<View
54-
style={{
55-
flex: 1,
56-
height: 600,
57-
}}
58-
>
64+
<View style={styles.modalContent}>
5965
<CrashView onReload={onReload} />
60-
6166
{visible && (
6267
<DeviceActivitySelectionView
63-
style={{
64-
flex: 1,
65-
height: 600,
66-
width: "100%",
67-
backgroundColor: "transparent",
68-
pointerEvents: "none",
69-
}}
70-
headerText="a header text!"
71-
footerText="a footer text!"
68+
style={styles.picker}
7269
onSelectionChange={onSelectionChange}
7370
familyActivitySelection={familyActivitySelection}
7471
/>
@@ -86,6 +83,7 @@ export const ActivityPickerPersisted = ({
8683
familyActivitySelectionId,
8784
onReload,
8885
includeEntireCategory,
86+
showNavigationBar = true,
8987
}: {
9088
visible: boolean;
9189
onDismiss: () => void;
@@ -96,35 +94,33 @@ export const ActivityPickerPersisted = ({
9694
familyActivitySelectionId: string;
9795
onReload: () => void;
9896
includeEntireCategory?: boolean;
97+
showNavigationBar?: boolean;
9998
}) => {
99+
if (showNavigationBar) {
100+
if (!visible) return null;
101+
return (
102+
<DeviceActivitySelectionSheetViewPersisted
103+
style={styles.nativeAnchor}
104+
onDismissRequest={onDismiss}
105+
onSelectionChange={onSelectionChange}
106+
familyActivitySelectionId={familyActivitySelectionId}
107+
includeEntireCategory={includeEntireCategory}
108+
/>
109+
);
110+
}
111+
100112
return (
101113
<Portal>
102114
<Modal
103115
visible={visible}
104116
onDismiss={onDismiss}
105-
contentContainerStyle={{
106-
height: 600,
107-
}}
117+
contentContainerStyle={styles.modalContainer}
108118
>
109-
<View
110-
style={{
111-
flex: 1,
112-
height: 600,
113-
}}
114-
>
119+
<View style={styles.modalContent}>
115120
<CrashView onReload={onReload} />
116-
117121
{visible && (
118122
<DeviceActivitySelectionViewPersisted
119-
style={{
120-
flex: 1,
121-
height: 600,
122-
width: "100%",
123-
backgroundColor: "transparent",
124-
pointerEvents: "none",
125-
}}
126-
headerText="a header text!"
127-
footerText="a footer text!"
123+
style={styles.picker}
128124
onSelectionChange={onSelectionChange}
129125
familyActivitySelectionId={familyActivitySelectionId}
130126
includeEntireCategory={includeEntireCategory}
@@ -135,3 +131,34 @@ export const ActivityPickerPersisted = ({
135131
</Portal>
136132
);
137133
};
134+
135+
const styles = StyleSheet.create({
136+
// Invisible anchor for the native .familyActivityPicker() modifier.
137+
nativeAnchor: {
138+
width: 1,
139+
height: 1,
140+
position: "absolute",
141+
},
142+
modalContainer: {
143+
height: 600,
144+
},
145+
modalContent: {
146+
flex: 1,
147+
height: 600,
148+
},
149+
crashView: {
150+
flex: 1,
151+
position: "absolute",
152+
height: 600,
153+
width: "100%",
154+
alignItems: "center",
155+
justifyContent: "center",
156+
backgroundColor: "white",
157+
},
158+
picker: {
159+
flex: 1,
160+
height: 600,
161+
width: "100%",
162+
backgroundColor: "transparent",
163+
},
164+
});

apps/example/components/CreateActivity.tsx

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { requestPermissionsAsync } from "expo-notifications";
22
import { useCallback, useState } from "react";
3-
import { NativeSyntheticEvent, View } from "react-native";
3+
import { NativeSyntheticEvent, Pressable, View } from "react-native";
44
import * as ReactNativeDeviceActivity from "react-native-device-activity";
55
import {
66
DeviceActivityEvent,
77
DeviceActivitySelectionEvent,
88
} from "react-native-device-activity/src/ReactNativeDeviceActivity.types";
99
import { Button, Text, TextInput, Title, useTheme } from "react-native-paper";
1010

11+
import { ActivityPicker } from "./ActivityPicker";
12+
1113
const trackEveryXMinutes = 10;
1214

1315
const potentialMaxEvents = Math.floor((60 * 24) / trackEveryXMinutes);
@@ -175,6 +177,7 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => {
175177
);
176178

177179
const [activityName, setActivityName] = useState("");
180+
const [showSelectionView, setShowSelectionView] = useState(false);
178181

179182
return (
180183
<View style={{ margin: 20 }}>
@@ -187,33 +190,19 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => {
187190
marginVertical: 10,
188191
}}
189192
>
190-
<ReactNativeDeviceActivity.DeviceActivitySelectionView
193+
<Pressable
191194
style={{
195+
backgroundColor: theme.colors.primary,
192196
width: 100,
193197
height: 40,
194198
borderRadius: 20,
195-
borderWidth: 10,
196-
borderColor: theme.colors.primary,
199+
alignItems: "center",
200+
justifyContent: "center",
197201
}}
198-
headerText="a header text!"
199-
footerText="a footer text!"
200-
onSelectionChange={onSelectionChange}
201-
familyActivitySelection={
202-
familyActivitySelectionResult?.familyActivitySelection
203-
}
202+
onPress={() => setShowSelectionView(true)}
204203
>
205-
<View
206-
pointerEvents="none"
207-
style={{
208-
backgroundColor: theme.colors.primary,
209-
flex: 1,
210-
alignItems: "center",
211-
justifyContent: "center",
212-
}}
213-
>
214-
<Text style={{ color: "white" }}>Select apps</Text>
215-
</View>
216-
</ReactNativeDeviceActivity.DeviceActivitySelectionView>
204+
<Text style={{ color: "white" }}>Select apps</Text>
205+
</Pressable>
217206
<Text>
218207
{familyActivitySelectionResult &&
219208
familyActivitySelectionResult?.categoryCount < 13
@@ -223,6 +212,20 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => {
223212
: "Nothing selected"}
224213
</Text>
225214
</View>
215+
<ActivityPicker
216+
visible={showSelectionView}
217+
onDismiss={() => setShowSelectionView(false)}
218+
onSelectionChange={onSelectionChange}
219+
familyActivitySelection={
220+
familyActivitySelectionResult?.familyActivitySelection ?? undefined
221+
}
222+
onReload={() => {
223+
setShowSelectionView(false);
224+
setTimeout(() => {
225+
setShowSelectionView(true);
226+
}, 100);
227+
}}
228+
/>
226229
<TextInput
227230
placeholder="Enter activity name"
228231
onChangeText={(text) => setActivityName(text)}

0 commit comments

Comments
 (0)