Skip to content
59 changes: 57 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,59 @@ 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 options

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
// The sheet view acts as an invisible anchor.
// The native side presents the iOS sheet and fires onDismissRequest on Cancel/Done.
{pickerVisible && (
<DeviceActivitySelectionSheetView
style={{ width: 1, height: 1, position: "absolute" }}
onDismissRequest={() => setPickerVisible(false)}
onSelectionChange={handleSelectionChange}
familyActivitySelection={familyActivitySelection}
/>
)}
```

**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.

```TypeScript
import { Modal, View } from "react-native";

<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onDismiss}
onDismiss={onDismiss}
>
<View style={{ flex: 1 }}>
<DeviceActivitySelectionView
style={{ flex: 1, width: "100%" }}
onSelectionChange={handleSelectionChange}
familyActivitySelection={familyActivitySelection}
/>
</View>
</Modal>
```

#### 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
import * as ReactNativeDeviceActivity from "react-native-device-activity";
Expand Down Expand Up @@ -550,7 +602,10 @@ For a complete implementation, see the [example app](https://github.com/Kingstin

| Component | Props | Description |
| ----------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `DeviceActivitySelectionView` | `familyActivitySelection`: string \| null<br>`onSelectionChange`: (event) => void<br>`style`: ViewStyle | Native component that renders the app selection UI |
| `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. |
| `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`. |
| `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. |
| `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`. |

### Hooks

Expand Down
123 changes: 75 additions & 48 deletions apps/example/components/ActivityPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Pressable, Text, View, NativeSyntheticEvent } from "react-native";
import React from "react";
import { NativeSyntheticEvent, Pressable, StyleSheet, Text, View } from "react-native";
import {
ActivitySelectionMetadata,
DeviceActivitySelectionSheetView,
DeviceActivitySelectionSheetViewPersisted,
ActivitySelectionWithMetadata,
DeviceActivitySelectionView,
DeviceActivitySelectionViewPersisted,
Expand All @@ -10,15 +13,7 @@ import { Modal, Portal } from "react-native-paper";
const CrashView = ({ onReload }: { onReload: () => void }) => {
return (
<Pressable
style={{
flex: 1,
position: "absolute",
height: 600,
width: "100%",
alignItems: "center",
justifyContent: "center",
backgroundColor: "white",
}}
style={styles.crashView}
onPress={onReload}
>
<Text>Swift view crash - tap to reload</Text>
Expand All @@ -32,6 +27,7 @@ export const ActivityPicker = ({
onSelectionChange,
familyActivitySelection,
onReload,
showNavigationBar = true,
}: {
visible: boolean;
onDismiss: () => void;
Expand All @@ -40,35 +36,36 @@ 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 (
<DeviceActivitySelectionSheetView
style={styles.nativeAnchor}
onDismissRequest={onDismiss}
onSelectionChange={onSelectionChange}
familyActivitySelection={familyActivitySelection}
/>
);
}

// Custom modal: react-native-paper Portal + Modal with fixed height.
return (
<Portal>
<Modal
visible={visible}
onDismiss={onDismiss}
contentContainerStyle={{
height: 600,
}}
contentContainerStyle={styles.modalContainer}
>
<View
style={{
flex: 1,
height: 600,
}}
>
<View style={styles.modalContent}>
<CrashView onReload={onReload} />

{visible && (
<DeviceActivitySelectionView
style={{
flex: 1,
height: 600,
width: "100%",
backgroundColor: "transparent",
pointerEvents: "none",
}}
headerText="a header text!"
footerText="a footer text!"
style={styles.picker}
onSelectionChange={onSelectionChange}
familyActivitySelection={familyActivitySelection}
/>
Expand All @@ -86,6 +83,7 @@ export const ActivityPickerPersisted = ({
familyActivitySelectionId,
onReload,
includeEntireCategory,
showNavigationBar = true,
}: {
visible: boolean;
onDismiss: () => void;
Expand All @@ -96,35 +94,33 @@ export const ActivityPickerPersisted = ({
familyActivitySelectionId: string;
onReload: () => void;
includeEntireCategory?: boolean;
showNavigationBar?: boolean;
}) => {
if (showNavigationBar) {
if (!visible) return null;
return (
<DeviceActivitySelectionSheetViewPersisted
style={styles.nativeAnchor}
onDismissRequest={onDismiss}
onSelectionChange={onSelectionChange}
familyActivitySelectionId={familyActivitySelectionId}
includeEntireCategory={includeEntireCategory}
/>
);
}

return (
<Portal>
<Modal
visible={visible}
onDismiss={onDismiss}
contentContainerStyle={{
height: 600,
}}
contentContainerStyle={styles.modalContainer}
>
<View
style={{
flex: 1,
height: 600,
}}
>
<View style={styles.modalContent}>
<CrashView onReload={onReload} />

{visible && (
<DeviceActivitySelectionViewPersisted
style={{
flex: 1,
height: 600,
width: "100%",
backgroundColor: "transparent",
pointerEvents: "none",
}}
headerText="a header text!"
footerText="a footer text!"
style={styles.picker}
onSelectionChange={onSelectionChange}
familyActivitySelectionId={familyActivitySelectionId}
includeEntireCategory={includeEntireCategory}
Expand All @@ -135,3 +131,34 @@ export const ActivityPickerPersisted = ({
</Portal>
);
};

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",
},
});
47 changes: 25 additions & 22 deletions apps/example/components/CreateActivity.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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,
DeviceActivitySelectionEvent,
} 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);
Expand Down Expand Up @@ -175,6 +177,7 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => {
);

const [activityName, setActivityName] = useState("");
const [showSelectionView, setShowSelectionView] = useState(false);

return (
<View style={{ margin: 20 }}>
Expand All @@ -187,33 +190,19 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => {
marginVertical: 10,
}}
>
<ReactNativeDeviceActivity.DeviceActivitySelectionView
<Pressable
style={{
backgroundColor: theme.colors.primary,
width: 100,
height: 40,
borderRadius: 20,
borderWidth: 10,
borderColor: theme.colors.primary,
alignItems: "center",
justifyContent: "center",
}}
headerText="a header text!"
footerText="a footer text!"
onSelectionChange={onSelectionChange}
familyActivitySelection={
familyActivitySelectionResult?.familyActivitySelection
}
onPress={() => setShowSelectionView(true)}
>
<View
pointerEvents="none"
style={{
backgroundColor: theme.colors.primary,
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "white" }}>Select apps</Text>
</View>
</ReactNativeDeviceActivity.DeviceActivitySelectionView>
<Text style={{ color: "white" }}>Select apps</Text>
</Pressable>
<Text>
{familyActivitySelectionResult &&
familyActivitySelectionResult?.categoryCount < 13
Expand All @@ -223,6 +212,20 @@ export const CreateActivity = ({ onDismiss }: { onDismiss: () => void }) => {
: "Nothing selected"}
</Text>
</View>
<ActivityPicker
visible={showSelectionView}
onDismiss={() => setShowSelectionView(false)}
onSelectionChange={onSelectionChange}
familyActivitySelection={
familyActivitySelectionResult?.familyActivitySelection ?? undefined
}
onReload={() => {
setShowSelectionView(false);
setTimeout(() => {
setShowSelectionView(true);
}, 100);
}}
/>
<TextInput
placeholder="Enter activity name"
onChangeText={(text) => setActivityName(text)}
Expand Down
Loading
Loading