├── .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 | --------------------------------------------------------------------------------