diff --git a/README.md b/README.md index 7a23380..5844bc1 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,58 @@ Setting `useHardwareLayer` rasterizes the view into a GPU texture for the durati No-op on iOS where Core Animation already composites off the main thread. +## Benchmarks + +The example app includes a benchmark that measures per-frame animation overhead across different approaches. All approaches run the same animation (translateX loop, linear, 2s) on a configurable number of views. + +### Android (release build, emulator, M4 MacBook Pro) + +UI thread time per frame: anim + layout + draw (ms). Lower is better. + +![Android benchmark](https://github.com/user-attachments/assets/f0e5cf26-76be-4dd3-ae04-e17c6d13b49c) + +
+Detailed numbers + +| Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated | +|-------|--------|------|---------------|---------------------|----------------|----------------------|-------------| +| 10 | Avg | 0.21 | 1.15 | 0.75 | 0.99 | 0.45 | 0.36 | +| 10 | P95 | 0.33 | 1.70 | 1.53 | 1.44 | 0.80 | 0.62 | +| 10 | P99 | 0.48 | 1.94 | 2.26 | 1.62 | 1.35 | 0.98 | +| 100 | Avg | 0.36 | 2.71 | 1.81 | 2.19 | 1.01 | 0.71 | +| 100 | P95 | 0.56 | 3.09 | 2.29 | 2.67 | 1.91 | 1.08 | +| 100 | P99 | 0.71 | 3.20 | 2.63 | 2.97 | 2.25 | 1.36 | +| 500 | Avg | 0.60 | 8.31 | 5.37 | 5.50 | 2.37 | 1.60 | +| 500 | P95 | 0.75 | 9.26 | 6.36 | 6.34 | 2.86 | 1.88 | +| 500 | P99 | 0.87 | 9.59 | 6.89 | 6.88 | 3.22 | 3.84 | + +
+ +### iOS (release build, simulator, iPhone 16 Pro, M4 MacBook Pro) + +Display link callback time per frame (ms). Lower is better. + +![iOS benchmark](https://github.com/user-attachments/assets/c39a7a71-bf21-4276-b02f-b29983989832) + +
+Detailed numbers + +| Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated | +|-------|--------|------|---------------|---------------------|----------------|----------------------|-------------| +| 10 | Avg | 0.01 | 1.33 | 1.08 | 1.06 | 0.63 | 0.83 | +| 10 | P95 | 0.02 | 1.67 | 1.59 | 1.34 | 1.01 | 1.18 | +| 10 | P99 | 0.03 | 1.90 | 1.68 | 1.50 | 1.08 | 1.31 | +| 100 | Avg | 0.01 | 3.72 | 3.33 | 2.71 | 2.48 | 3.32 | +| 100 | P95 | 0.01 | 5.21 | 4.50 | 3.83 | 3.39 | 4.28 | +| 100 | P99 | 0.02 | 5.68 | 4.75 | 4.91 | 3.79 | 4.55 | +| 500 | Avg | 0.01 | 6.84 | 6.54 | 4.16 | 3.70 | 4.91 | +| 500 | P95 | 0.01 | 7.69 | 7.32 | 4.59 | 4.22 | 5.66 | +| 500 | P99 | 0.02 | 8.10 | 7.45 | 4.71 | 4.33 | 5.89 | + +
+ +Ease stays near zero because animations run entirely on platform APIs. On iOS, Core Animation runs on a separate render server process off the main thread, which is why Ease shows ~0ms. On Android, ObjectAnimator runs on the UI thread but is significantly lighter than other approaches. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). + ## How It Works `EaseView` is a native Fabric component. The JS side flattens your `animate` and `transition` props into flat native props. When those props change, the native view: diff --git a/example/.gitignore b/example/.gitignore index 03d5ee3..0b9ce13 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -7,3 +7,7 @@ web-build/ # Expo prebuild generated dirs android/ ios/ + +# Keep local modules native code +!modules/**/android/ +!modules/**/ios/ diff --git a/example/modules/frame-metrics/android/build.gradle b/example/modules/frame-metrics/android/build.gradle new file mode 100644 index 0000000..b8049de --- /dev/null +++ b/example/modules/frame-metrics/android/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +android { + namespace "expo.modules.framemetrics" + defaultConfig { + versionCode 1 + versionName "1.0.0" + } +} diff --git a/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt new file mode 100644 index 0000000..aee368b --- /dev/null +++ b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt @@ -0,0 +1,87 @@ +package expo.modules.framemetrics + +import android.os.Handler +import android.os.HandlerThread +import android.view.FrameMetrics +import android.view.Window +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.util.concurrent.CopyOnWriteArrayList + +private data class FrameSample( + val animationMs: Double, + val layoutMs: Double, + val drawMs: Double, +) + +class FrameMetricsModule : Module() { + private val frameSamples = CopyOnWriteArrayList() + private var listener: Window.OnFrameMetricsAvailableListener? = null + private var handlerThread: HandlerThread? = null + + override fun definition() = ModuleDefinition { + Name("FrameMetrics") + + Function("startCollecting") { + frameSamples.clear() + + val activity = appContext.currentActivity + ?: throw IllegalStateException("No activity") + + val thread = HandlerThread("FrameMetricsHandler").also { it.start() } + handlerThread = thread + val handler = Handler(thread.looper) + + val l = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + frameSamples.add(FrameSample( + animationMs = frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) / 1_000_000.0, + layoutMs = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) / 1_000_000.0, + drawMs = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION) / 1_000_000.0, + )) + } + listener = l + + activity.runOnUiThread { + activity.window.addOnFrameMetricsAvailableListener(l, handler) + } + } + + Function("stopCollecting") { + val activity = appContext.currentActivity + val l = listener + if (activity != null && l != null) { + activity.runOnUiThread { + try { + activity.window.removeOnFrameMetricsAvailableListener(l) + } catch (_: Exception) {} + } + } + listener = null + handlerThread?.quitSafely() + handlerThread = null + + val samples = frameSamples.toList() + if (samples.isEmpty()) { + return@Function mapOf( + "avgUiThreadTime" to 0.0, + "p95UiThreadTime" to 0.0, + "p99UiThreadTime" to 0.0, + "avgAnimationTime" to 0.0, + "avgLayoutTime" to 0.0, + "avgDrawTime" to 0.0, + ) + } + + val uiThreadDurations = samples.map { it.animationMs + it.layoutMs + it.drawMs }.sorted() + + mapOf( + "avgUiThreadTime" to uiThreadDurations.average(), + "p95UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.95).toInt().coerceAtMost(uiThreadDurations.size - 1)], + "p99UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.99).toInt().coerceAtMost(uiThreadDurations.size - 1)], + "avgAnimationTime" to samples.map { it.animationMs }.average(), + "avgLayoutTime" to samples.map { it.layoutMs }.average(), + "avgDrawTime" to samples.map { it.drawMs }.average(), + ) + } + } +} diff --git a/example/modules/frame-metrics/expo-module.config.json b/example/modules/frame-metrics/expo-module.config.json new file mode 100644 index 0000000..198c3a0 --- /dev/null +++ b/example/modules/frame-metrics/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["FrameMetricsModule"] + }, + "android": { + "modules": ["expo.modules.framemetrics.FrameMetricsModule"] + } +} diff --git a/example/modules/frame-metrics/ios/FrameMetrics.podspec b/example/modules/frame-metrics/ios/FrameMetrics.podspec new file mode 100644 index 0000000..03f3374 --- /dev/null +++ b/example/modules/frame-metrics/ios/FrameMetrics.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'FrameMetrics' + s.version = '1.0.0' + s.summary = 'Frame metrics collection module' + s.description = 'Expo module for collecting frame timing metrics' + s.license = 'MIT' + s.author = 'Janic Duplessis' + s.homepage = 'https://github.com/AppAndFlow/react-native-ease' + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/AppAndFlow/react-native-ease.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.source_files = "**/*.{h,m,swift}" + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } +end diff --git a/example/modules/frame-metrics/ios/FrameMetricsModule.swift b/example/modules/frame-metrics/ios/FrameMetricsModule.swift new file mode 100644 index 0000000..6ddac6d --- /dev/null +++ b/example/modules/frame-metrics/ios/FrameMetricsModule.swift @@ -0,0 +1,139 @@ +import ExpoModulesCore +import QuartzCore +import ObjectiveC + +// MARK: - Display Link Swizzle + +/// Aggregates per-frame callback durations across all swizzled CADisplayLinks. +final class FrameCallbackCollector { + static let shared = FrameCallbackCollector() + + var isCollecting = false + private var currentFrameTimestamp: CFTimeInterval = 0 + private var currentFrameWorkMs: Double = 0 + private(set) var frameWorkTimes: [Double] = [] + + func reset() { + isCollecting = false + currentFrameTimestamp = 0 + currentFrameWorkMs = 0 + frameWorkTimes = [] + } + + func finalize() { + if currentFrameWorkMs > 0 { + frameWorkTimes.append(currentFrameWorkMs) + } + isCollecting = false + } + + func recordCallback(timestamp: CFTimeInterval, durationMs: Double) { + guard isCollecting else { return } + if timestamp != currentFrameTimestamp { + if currentFrameTimestamp > 0 { + frameWorkTimes.append(currentFrameWorkMs) + } + currentFrameTimestamp = timestamp + currentFrameWorkMs = 0 + } + currentFrameWorkMs += durationMs + } +} + +/// Wraps an original CADisplayLink target to measure callback duration. +final class DisplayLinkProxy: NSObject { + let originalTarget: AnyObject + let originalSelector: Selector + + init(target: AnyObject, selector: Selector) { + self.originalTarget = target + self.originalSelector = selector + super.init() + } + + @objc func proxyCallback(_ displayLink: CADisplayLink) { + let start = CACurrentMediaTime() + _ = originalTarget.perform(originalSelector, with: displayLink) + let elapsed = (CACurrentMediaTime() - start) * 1000.0 + FrameCallbackCollector.shared.recordCallback( + timestamp: displayLink.timestamp, + durationMs: elapsed + ) + } +} + +private var proxyAssocKey: UInt8 = 0 +private var originalFactoryIMP: IMP? +private var didSwizzle = false + +private func swizzleDisplayLinkFactory() { + guard !didSwizzle else { return } + didSwizzle = true + + let metaCls: AnyClass = object_getClass(CADisplayLink.self)! + let sel = NSSelectorFromString("displayLinkWithTarget:selector:") + guard let method = class_getInstanceMethod(metaCls, sel) else { return } + + originalFactoryIMP = method_getImplementation(method) + + typealias OrigFunc = @convention(c) (AnyClass, Selector, AnyObject, Selector) -> CADisplayLink + + let block: @convention(block) (AnyObject, AnyObject, Selector) -> CADisplayLink = { + _, target, selector in + let proxy = DisplayLinkProxy(target: target, selector: selector) + let orig = unsafeBitCast(originalFactoryIMP!, to: OrigFunc.self) + let factorySel = NSSelectorFromString("displayLinkWithTarget:selector:") + let link = orig( + CADisplayLink.self, factorySel, proxy, + #selector(DisplayLinkProxy.proxyCallback(_:))) + // Keep proxy alive via associated object + objc_setAssociatedObject(link, &proxyAssocKey, proxy, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return link + } + + let imp = imp_implementationWithBlock(unsafeBitCast(block, to: AnyObject.self)) + method_setImplementation(method, imp) +} + +// MARK: - Module + +public final class FrameMetricsModule: Module { + public func definition() -> ModuleDefinition { + Name("FrameMetrics") + + OnCreate { + swizzleDisplayLinkFactory() + } + + Function("startCollecting") { + let collector = FrameCallbackCollector.shared + collector.reset() + collector.isCollecting = true + } + + Function("stopCollecting") { () -> [String: Any] in + let collector = FrameCallbackCollector.shared + collector.finalize() + let workTimes = collector.frameWorkTimes + + guard !workTimes.isEmpty else { + return [ + "avgUiThreadTime": 0, + "p95UiThreadTime": 0, + "p99UiThreadTime": 0, + ] + } + + let sorted = workTimes.sorted() + let avg = sorted.reduce(0, +) / Double(sorted.count) + let p95 = sorted[min(Int(Double(sorted.count) * 0.95), sorted.count - 1)] + let p99 = sorted[min(Int(Double(sorted.count) * 0.99), sorted.count - 1)] + + return [ + "avgUiThreadTime": avg, + "p95UiThreadTime": p95, + "p99UiThreadTime": p99, + ] + } + } +} diff --git a/example/modules/frame-metrics/src/index.ts b/example/modules/frame-metrics/src/index.ts new file mode 100644 index 0000000..f23039f --- /dev/null +++ b/example/modules/frame-metrics/src/index.ts @@ -0,0 +1,35 @@ +import { NativeModule, requireNativeModule, Platform } from 'expo-modules-core'; + +interface FrameMetricsResult { + /** Average UI/main thread work time per frame (ms) */ + avgUiThreadTime: number; + /** P95 UI/main thread work time (ms) */ + p95UiThreadTime: number; + /** P99 UI/main thread work time (ms) */ + p99UiThreadTime: number; + /** Average time spent evaluating animators (Android only) */ + avgAnimationTime?: number; + /** Average layout/measure time (Android only) */ + avgLayoutTime?: number; + /** Average draw time (Android only) */ + avgDrawTime?: number; +} + +declare class FrameMetricsModuleType extends NativeModule { + startCollecting(): void; + stopCollecting(): FrameMetricsResult; +} + +const mod = requireNativeModule('FrameMetrics'); + +export const isAndroid = Platform.OS === 'android'; + +export function startCollecting(): void { + mod.startCollecting(); +} + +export function stopCollecting(): FrameMetricsResult { + return mod.stopCollecting(); +} + +export type { FrameMetricsResult }; diff --git a/example/package.json b/example/package.json index 53e7b2d..d21ccc9 100644 --- a/example/package.json +++ b/example/package.json @@ -48,5 +48,13 @@ }, "resolutions": { "@react-native/gradle-plugin": "0.83.0" + }, + "reanimated": { + "staticFeatureFlags": { + "ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS": true, + "IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS": true, + "USE_SYNCHRONIZABLE_FOR_MUTABLES": true, + "DISABLE_COMMIT_PAUSING_MECHANISM": true + } } } diff --git a/example/src/demos/BenchmarkDemo.tsx b/example/src/demos/BenchmarkDemo.tsx new file mode 100644 index 0000000..530cdb3 --- /dev/null +++ b/example/src/demos/BenchmarkDemo.tsx @@ -0,0 +1,596 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, + Text, + StyleSheet, + Animated as RNAnimated, + Easing as RNEasing, + Pressable, +} from 'react-native'; +import { EaseView } from 'react-native-ease'; +import Animated, { + createCSSAnimatedComponent, + css, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import { + isAndroid, + startCollecting, + stopCollecting, + type FrameMetricsResult, +} from '../../modules/frame-metrics/src'; + +const ANIMATION_DISTANCE = 100; +const ANIMATION_DURATION = 2000; +const BENCHMARK_DURATION = 5000; +const SETTLE_DELAY = 500; + +const BOX_SIZE = 20; +const BOX_MARGIN = 2; + +// --------------------------------------------------------------------------- +// Animation approaches +// --------------------------------------------------------------------------- + +function EaseBox() { + return ( + + ); +} + +function EaseApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +function ReanimatedSVBox() { + const translateX = useSharedValue(0); + + useEffect(() => { + translateX.value = withRepeat( + withTiming(ANIMATION_DISTANCE, { + duration: ANIMATION_DURATION, + easing: Easing.linear, + }), + -1, + true, + ); + }, [translateX]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + return ; +} + +function ReanimatedSVApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +const CSSView = createCSSAnimatedComponent(View); + +const slideKeyframes = css.keyframes({ + from: { transform: [{ translateX: 0 }] }, + to: { transform: [{ translateX: ANIMATION_DISTANCE }] }, +}); + +const cssStyles = css.create({ + box: { + animationName: slideKeyframes, + animationDuration: `${ANIMATION_DURATION}ms`, + animationIterationCount: 'infinite', + animationDirection: 'alternate', + animationTimingFunction: 'linear', + }, +}); + +function ReanimatedCSSBox() { + return ; +} + +function ReanimatedCSSApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +function AnimatedBox() { + const translateX = useRef(new RNAnimated.Value(0)).current; + + useEffect(() => { + const animation = RNAnimated.loop( + RNAnimated.sequence([ + RNAnimated.timing(translateX, { + toValue: ANIMATION_DISTANCE, + duration: ANIMATION_DURATION, + easing: RNEasing.linear, + useNativeDriver: true, + }), + RNAnimated.timing(translateX, { + toValue: 0, + duration: ANIMATION_DURATION, + easing: RNEasing.linear, + useNativeDriver: true, + }), + ]), + ); + animation.start(); + return () => animation.stop(); + }, [translateX]); + + return ( + + ); +} + +function AnimatedApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Approach registry +// --------------------------------------------------------------------------- + +const APPROACHES = [ + { key: 'ease', label: 'Ease', color: '#4a90d9', component: EaseApproach }, + { + key: 'reanimated-sv', + label: 'Reanimated SV', + color: '#6c5ce7', + component: ReanimatedSVApproach, + }, + { + key: 'reanimated-css', + label: 'Reanimated CSS', + color: '#a55eea', + component: ReanimatedCSSApproach, + }, + { + key: 'animated', + label: 'RN Animated', + color: '#e17055', + component: AnimatedApproach, + }, +] as const; + +type ApproachKey = (typeof APPROACHES)[number]['key']; + +// --------------------------------------------------------------------------- +// Benchmark component +// --------------------------------------------------------------------------- + +export function BenchmarkDemo() { + const [viewCount, setViewCount] = useState(100); + const [activeApproach, setActiveApproach] = useState( + null, + ); + const [results, setResults] = useState< + Partial> + >({}); + const [running, setRunning] = useState(false); + const [statusText, setStatusText] = useState(''); + const abortRef = useRef(false); + + const runSingle = useCallback( + (key: ApproachKey): Promise => { + return new Promise((resolve) => { + setActiveApproach(key); + + // Let views mount and settle + setTimeout(() => { + if (abortRef.current) { + resolve(null); + return; + } + startCollecting(); + + setTimeout(() => { + const result = stopCollecting(); + setActiveApproach(null); + resolve(result); + }, BENCHMARK_DURATION); + }, SETTLE_DELAY); + }); + }, + [], + ); + + const runBenchmark = useCallback( + async (keys: ApproachKey[]) => { + abortRef.current = false; + setRunning(true); + setResults({}); + + for (const key of keys) { + if (abortRef.current) { + break; + } + setStatusText( + `Running: ${APPROACHES.find((a) => a.key === key)?.label}...`, + ); + const result = await runSingle(key); + if (result) { + setResults((prev) => ({ ...prev, [key]: result })); + } + // Brief pause between approaches + await new Promise((r) => setTimeout(r, 300)); + } + + setStatusText(''); + setRunning(false); + }, + [runSingle], + ); + + const stopBenchmark = useCallback(() => { + abortRef.current = true; + try { + stopCollecting(); + } catch {} + setActiveApproach(null); + setRunning(false); + setStatusText(''); + }, []); + + // Render the active approach + const ActiveComponent = activeApproach + ? APPROACHES.find((a) => a.key === activeApproach)?.component + : null; + + return ( + + Animation Benchmark + + Measures per-frame time with {viewCount} simultaneously animating views. + {'\n'}Lower is better. + + + {/* View count slider */} + + Views: {viewCount} + + {[10, 50, 100, 200, 500].map((n) => ( + !running && setViewCount(n)} + > + + {n} + + + ))} + + + + {/* Run buttons */} + + {running ? ( + + Stop + + ) : ( + <> + {APPROACHES.map((a) => ( + runBenchmark([a.key])} + > + {a.label} + + ))} + runBenchmark(APPROACHES.map((a) => a.key))} + > + Run All + + + )} + + + {/* Status / active animation area */} + {running && ( + + {statusText} + {activeApproach && ActiveComponent && ( + + )} + + )} + + {/* Results */} + {Object.keys(results).length > 0 && ( + + Results + + {/* Primary table: main thread work per frame */} + + {isAndroid + ? 'UI thread time per frame: anim + layout + draw (ms). Lower is better.' + : 'Display link callback time per frame (ms). Lower is better.'} + + + + Approach + + Avg + P95 + P99 + + {APPROACHES.filter((a) => results[a.key]).map((a) => { + const r = results[a.key]!; + const avg = r.avgUiThreadTime; + const p95 = r.p95UiThreadTime; + const p99 = r.p99UiThreadTime; + return ( + + + {a.label} + + + {avg.toFixed(2)} + + + {p95.toFixed(2)} + + + {p99.toFixed(2)} + + + ); + })} + + {/* Android-only: full breakdown */} + {isAndroid && ( + <> + + UI Thread Breakdown (avg ms) + + + + Approach + + Anim + Layout + Draw + + {APPROACHES.filter((a) => results[a.key]).map((a) => { + const r = results[a.key]!; + return ( + + + {a.label} + + + {(r.avgAnimationTime ?? 0).toFixed(2)} + + + {(r.avgLayoutTime ?? 0).toFixed(2)} + + + {(r.avgDrawTime ?? 0).toFixed(2)} + + + ); + })} + + )} + + )} + + ); +} + +function overheadColor(overheadMs: number): { color: string } { + if (overheadMs < 2) { + return { color: '#2ecc71' }; + } + if (overheadMs < 5) { + return { color: '#f39c12' }; + } + return { color: '#e74c3c' }; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + title: { + fontSize: 28, + fontWeight: '800', + color: '#fff', + marginBottom: 4, + }, + subtitle: { + fontSize: 13, + color: '#8888aa', + marginBottom: 20, + lineHeight: 18, + }, + sliderSection: { + marginBottom: 16, + }, + sliderLabel: { + fontSize: 14, + fontWeight: '600', + color: '#e0e0ff', + marginBottom: 8, + }, + sliderRow: { + flexDirection: 'row', + gap: 8, + }, + countButton: { + paddingHorizontal: 14, + paddingVertical: 8, + backgroundColor: '#2a2a4a', + borderRadius: 8, + }, + countButtonActive: { + backgroundColor: '#4a90d9', + }, + countButtonText: { + color: '#8888aa', + fontWeight: '600', + fontSize: 14, + }, + countButtonTextActive: { + color: '#fff', + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 16, + }, + runButton: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 8, + }, + runAllButton: { + backgroundColor: '#2a2a4a', + }, + stopButton: { + backgroundColor: '#d94a4a', + }, + runButtonText: { + color: '#fff', + fontWeight: '700', + fontSize: 13, + }, + animationArea: { + backgroundColor: '#16213e', + borderRadius: 16, + padding: 12, + marginBottom: 16, + maxHeight: 200, + overflow: 'hidden', + }, + runningLabel: { + fontSize: 13, + fontWeight: '600', + color: '#8888aa', + marginBottom: 8, + }, + boxContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + box: { + width: BOX_SIZE, + height: BOX_SIZE, + backgroundColor: '#4a90d9', + borderRadius: 4, + margin: BOX_MARGIN, + }, + resultsSection: { + backgroundColor: '#16213e', + borderRadius: 16, + padding: 16, + }, + resultsSectionTitle: { + fontSize: 16, + fontWeight: '700', + color: '#e0e0ff', + marginBottom: 12, + }, + subtitleText: { + fontSize: 13, + color: '#8888aa', + marginBottom: 12, + }, + breakdownTitle: { + marginTop: 20, + }, + tableHeader: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#2a2a4a', + paddingBottom: 8, + marginBottom: 4, + }, + tableRow: { + flexDirection: 'row', + paddingVertical: 6, + }, + tableCell: { + flex: 1, + fontSize: 13, + color: '#e0e0ff', + textAlign: 'center', + fontVariant: ['tabular-nums'], + }, + tableCellLabel: { + flex: 1.5, + textAlign: 'left', + fontWeight: '600', + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index c97dd27..7a846ae 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import { BackgroundColorDemo } from './BackgroundColorDemo'; +import { BenchmarkDemo } from './BenchmarkDemo'; import { BannerDemo } from './BannerDemo'; import { BorderRadiusDemo } from './BorderRadiusDemo'; import { ButtonDemo } from './ButtonDemo'; @@ -78,6 +79,11 @@ export const demos: Record = { title: 'Comparison', section: 'Advanced', }, + 'benchmark': { + component: BenchmarkDemo, + title: 'Benchmark', + section: 'Advanced', + }, }; interface SectionData {