├── .gitignore
├── App.tsx
├── README.md
├── app.json
├── assets
├── adaptive-icon.png
├── favicon.png
├── icon.png
└── splash.png
├── babel.config.js
├── bun.lockb
├── modules
└── haptic-engine
│ ├── expo-module.config.json
│ ├── index.ts
│ └── ios
│ ├── HapticEngine.podspec
│ └── HapticEngineModule.swift
├── package.json
├── src
├── ArcScrollView.ios.tsx
└── ArcScrollView.tsx
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import { StatusBar } from "expo-status-bar";
2 | import { useState } from "react";
3 | import {
4 | RefreshControl,
5 | SafeAreaView,
6 | StyleSheet,
7 | Text,
8 | View,
9 | } from "react-native";
10 | import { ArcScrollView } from "./src/ArcScrollView";
11 |
12 | export default function App() {
13 | const [refreshing, setRefreshing] = useState(false);
14 | return (
15 |
16 | {
21 | setRefreshing(true);
22 | setTimeout(() => setRefreshing(false), 500);
23 | }}
24 | />
25 | }
26 | style={{
27 | flex: 1,
28 | backgroundColor: "white",
29 | }}
30 | >
31 |
32 | Pull to refresh ↓
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | const styles = StyleSheet.create({
41 | container: {
42 | flex: 1,
43 | backgroundColor: "#fff",
44 | alignItems: "center",
45 | justifyContent: "center",
46 | },
47 | });
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Arc Browser Pull-to-Refresh Haptics in Expo
2 |
3 | This is a simple replication of the pull-to-refresh haptics in the Arc Browser app. When you scroll down the haptics slowly get more intense with a slight punchy feel, sort of like pulling a rubber band. At the apex of the pull, the haptics have a strong punch to indicate that the refresh has been triggered.
4 |
5 | This feature is iOS-only and required a Swift module to maintain the haptic engine, the engine is restored when the app is resumed according to the Apple docs: [Updating Continuous and Transient Haptic Parameters in Real Time](https://developer.apple.com/documentation/corehaptics/updating-continuous-and-transient-haptic-parameters-in-real-time).
6 |
7 | To run the project:
8 |
9 | - `bun install`
10 | - `npx expo run:ios -d` -> choose a physical device
11 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "arc-scroll-haptics",
4 | "slug": "arc-scroll-haptics",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "ios": {
15 | "supportsTablet": true,
16 | "bundleIdentifier": "com.bacon.arc-scroll-haptics"
17 | },
18 | "android": {
19 | "adaptiveIcon": {
20 | "foregroundImage": "./assets/adaptive-icon.png",
21 | "backgroundColor": "#ffffff"
22 | }
23 | },
24 | "web": {
25 | "favicon": "./assets/favicon.png"
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/arc-browser-pull-to-refresh-haptics-in-Expo/7f1b9f447c8f675ff5a2daaa99ff5270a2f4702c/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/arc-browser-pull-to-refresh-haptics-in-Expo/7f1b9f447c8f675ff5a2daaa99ff5270a2f4702c/assets/favicon.png
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/arc-browser-pull-to-refresh-haptics-in-Expo/7f1b9f447c8f675ff5a2daaa99ff5270a2f4702c/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/arc-browser-pull-to-refresh-haptics-in-Expo/7f1b9f447c8f675ff5a2daaa99ff5270a2f4702c/assets/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/arc-browser-pull-to-refresh-haptics-in-Expo/7f1b9f447c8f675ff5a2daaa99ff5270a2f4702c/bun.lockb
--------------------------------------------------------------------------------
/modules/haptic-engine/expo-module.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "platforms": ["ios"],
3 | "ios": {
4 | "modules": ["HapticEngineModule"]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/modules/haptic-engine/index.ts:
--------------------------------------------------------------------------------
1 | // noop
2 |
--------------------------------------------------------------------------------
/modules/haptic-engine/ios/HapticEngine.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'HapticEngine'
3 | s.version = '1.0.0'
4 | s.summary = 'A sample project summary'
5 | s.description = 'A sample project description'
6 | s.author = ''
7 | s.homepage = 'https://docs.expo.dev/modules/'
8 | s.platforms = { :ios => '13.4', :tvos => '13.4' }
9 | s.source = { git: '' }
10 | s.static_framework = true
11 |
12 | s.dependency 'ExpoModulesCore'
13 |
14 | # Swift/Objective-C compatibility
15 | s.pod_target_xcconfig = {
16 | 'DEFINES_MODULE' => 'YES',
17 | 'SWIFT_COMPILATION_MODE' => 'wholemodule'
18 | }
19 |
20 | s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
21 | end
22 |
--------------------------------------------------------------------------------
/modules/haptic-engine/ios/HapticEngineModule.swift:
--------------------------------------------------------------------------------
1 | import ExpoModulesCore
2 | import CoreHaptics
3 |
4 | public class HapticEngineModule: Module {
5 | // Each module class must implement the definition function. The definition consists of components
6 | // that describes the module's functionality and behavior.
7 | // See https://docs.expo.dev/modules/module-api for more details about available components.
8 | // Create a property to store the CHHapticEngine instance
9 | var hapticEngine: CHHapticEngine?
10 |
11 | public func definition() -> ModuleDefinition {
12 | // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
13 | // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
14 | // The module will be accessible from `requireNativeModule('HapticEngine')` in JavaScript.
15 | Name("HapticEngine")
16 |
17 | // Function to create and start the haptic engine
18 | Function("createEngine") { () -> Void in
19 | do {
20 | self.hapticEngine = try CHHapticEngine()
21 | try self.hapticEngine?.start()
22 | self.hapticEngine?.resetHandler = {
23 | print("Reset Handler: Restarting the engine.")
24 | do {
25 | try self.hapticEngine?.start()
26 | } catch {
27 | print("Failed to start the engine")
28 | }
29 | }
30 | } catch {
31 | print("Failed to create and start the haptic engine: \(error)")
32 | }
33 | }
34 |
35 | // Function to stop and destroy the haptic engine
36 | Function("destroyEngine") { () -> Void in
37 | self.hapticEngine?.stop()
38 | self.hapticEngine = nil
39 | }
40 |
41 | // Function to play a haptic pattern
42 | AsyncFunction("playHapticPattern") { (sharpness: Double, intensity: Double) in
43 | guard let engine = self.hapticEngine else {
44 | print("Haptic engine not initialized")
45 | return
46 | }
47 |
48 | let sharpnessParameter = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(sharpness))
49 | let intensityParameter = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(intensity))
50 |
51 | do {
52 | let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensityParameter, sharpnessParameter], relativeTime: 0)
53 |
54 | let pattern = try CHHapticPattern(events: [event], parameters: [])
55 | let player = try engine.makePlayer(with: pattern)
56 | try player.start(atTime: 0)
57 | } catch {
58 | print("Failed to play haptic pattern: \(error)")
59 | }
60 | }.runOnQueue(.main)
61 |
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "arc-scroll-haptics",
3 | "version": "1.0.0",
4 | "main": "expo/AppEntry.js",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web"
10 | },
11 | "dependencies": {
12 | "@types/react": "~18.2.79",
13 | "expo": "~51.0.28",
14 | "expo-status-bar": "~1.12.1",
15 | "react": "18.2.0",
16 | "react-native": "0.74.5",
17 | "typescript": "~5.3.3"
18 | },
19 | "devDependencies": {
20 | "@babel/core": "^7.20.0"
21 | },
22 | "private": true
23 | }
24 |
--------------------------------------------------------------------------------
/src/ArcScrollView.ios.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import {
3 | AppState,
4 | Dimensions,
5 | ScrollView,
6 | ScrollViewProps,
7 | Easing,
8 | } from "react-native";
9 |
10 | const hap = globalThis.expo?.modules?.HapticEngine as {
11 | /** Create the haptics engine. */
12 | createEngine: () => void;
13 | /** Destroy the engine. */
14 | destroyEngine: () => void;
15 | /** Play a `CHHapticPattern` haptic pattern with a given sharpness and intensity between 0-1. */
16 | playHapticPattern: (sharpness: number, intensity: number) => Promise;
17 | };
18 |
19 | function useHapticsEngine() {
20 | if (!hap) return;
21 | useEffect(() => {
22 | hap.createEngine();
23 |
24 | // The engine must be recreated when the app resumes.
25 | const off = AppState.addEventListener("change", (state) => {
26 | if (state === "active") {
27 | hap.createEngine();
28 | } else if (state === "background") {
29 | hap.destroyEngine();
30 | }
31 | });
32 |
33 | return () => {
34 | off.remove();
35 | };
36 | }, []);
37 | }
38 |
39 | function playHaptic(intensity: number) {
40 | if (!hap) return;
41 | if (intensity >= 1) {
42 | hap.playHapticPattern(1, 1);
43 | } else {
44 | hap.playHapticPattern(
45 | // Sharpness. Adding a baseline of 0.2 and then scaling the intensity by 0.2 gives a nice range of haptic feedback.
46 | 0.2 + intensity * 0.2,
47 | // Intensity. Scaling from no intensity to half intensity by the end. Avoid using 1 as it will conflict with the final haptic event.
48 | intensity * 0.5
49 | );
50 | }
51 | }
52 |
53 | // Threshold for triggering a haptic event, the larger this number is, the longer the gesture before the haptic event is triggered.
54 | // Spacing this out gives a bit more texture to the event, helping each tap to feel more distinct.
55 | // Lower numbers like 2-3 feel like a rubber band, while higher numbers (5-10) feel like a gear or a roller coaster.
56 | const TAP_DISTANCE = 2;
57 | // This is just a guess based on my iPhone 14 pro max. This should be the distance the user needs to pull down to trigger the refresh.
58 | const refreshDistanceThreshold = Dimensions.get("window").height * 0.178111588;
59 |
60 | export function ArcScrollView(props: ScrollViewProps) {
61 | useHapticsEngine();
62 |
63 | const isTouching = useRef(false);
64 | const lastPosition = useRef(0);
65 | const hasPopped = useRef(false);
66 |
67 | return (
68 | {
71 | // The haptics map to the user scrolling, disable them when the user is not touching the screen.
72 | isTouching.current = true;
73 | props.onScrollBeginDrag?.(event);
74 | }}
75 | onScrollEndDrag={(event) => {
76 | isTouching.current = false;
77 | lastPosition.current = event.nativeEvent.contentOffset.y;
78 | props.onScrollEndDrag?.(event);
79 | }}
80 | onScroll={(event) => {
81 | props.onScroll?.(event);
82 | if (!isTouching.current) {
83 | return;
84 | }
85 |
86 | const offset = Math.max(-event.nativeEvent.contentOffset.y, 0);
87 | const threshold = refreshDistanceThreshold;
88 |
89 | const absProgress = offset / threshold;
90 | const progress = Math.min(absProgress, 1.0);
91 |
92 | // Apply ease-in effect to the progress
93 | const easedProgress = Easing.ease(progress);
94 |
95 | if (Math.abs(offset - lastPosition.current) >= TAP_DISTANCE) {
96 | if (hasPopped.current) {
97 | if (easedProgress < 1) {
98 | hasPopped.current = false;
99 | }
100 | return;
101 | } else {
102 | if (easedProgress >= 1) {
103 | hasPopped.current = true;
104 | }
105 | playHaptic(easedProgress);
106 | }
107 |
108 | lastPosition.current = offset;
109 | }
110 | }}
111 | />
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/src/ArcScrollView.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollView } from "react-native";
2 |
3 | export const ArcScrollView = ScrollView;
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {},
3 | "extends": "expo/tsconfig.base"
4 | }
5 |
--------------------------------------------------------------------------------