diff --git a/example/src/App.tsx b/example/src/App.tsx index 216cbb04c9..4298fe44ae 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -28,6 +28,7 @@ import { Transitions, Stickers, FrostedCard, + SpeedTest, } from "./Examples"; import { CI, Tests } from "./Tests"; import { HomeScreen } from "./Home"; @@ -59,6 +60,7 @@ const linking: LinkingOptions = { Transitions: "transitions", Stickers: "stickers", FrostedCard: "frosted-card", + SpeedTest: "speedtest", }, }, prefixes: ["rnskia://"], @@ -146,6 +148,13 @@ const App = () => { header: () => null, }} /> + null, + }} + /> { + const { goBack } = useNavigation(); + const insets = useSafeAreaInsets(); + + const styles = StyleSheet.create({ + container: { + position: "absolute", + top: insets.top, + left: insets.left, + zIndex: 1, + }, + }); + + return ( + + + + + + + + + + ); +}; diff --git a/example/src/Examples/SpeedTest/Slider.tsx b/example/src/Examples/SpeedTest/Slider.tsx new file mode 100644 index 0000000000..9e1e1afc49 --- /dev/null +++ b/example/src/Examples/SpeedTest/Slider.tsx @@ -0,0 +1,104 @@ +import { StyleSheet, View, useWindowDimensions } from "react-native"; +import React from "react"; +import Animated, { + interpolate, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; + +interface Props { + onValueChange: (value: number) => void; + minValue: number; + maxValue: number; +} + +const size = 32; + +const clamp = (value: number, lowerBound: number, upperBound: number) => { + "worklet"; + return Math.min(Math.max(value, lowerBound), upperBound); +}; + +export const Slider: React.FC = ({ + onValueChange, + minValue, + maxValue, +}) => { + const { width } = useWindowDimensions(); + + const sliderWidth = width / 2; + const pickerR = size / 2; + const progressBarHeight = 3; + + const translateX = useSharedValue(-pickerR); + const contextX = useSharedValue(0); + const scale = useSharedValue(1); + + const styles = StyleSheet.create({ + picker: { + position: "absolute", + width: size, + height: size, + borderRadius: pickerR, + backgroundColor: "white", + }, + progressBar: { + height: progressBarHeight, + backgroundColor: "rgba(255,255,255,0.5)", + width: sliderWidth, + }, + }); + + useAnimatedReaction( + () => translateX.value, + (value) => { + const progress = interpolate( + value, + [-pickerR, sliderWidth - pickerR], + [minValue, maxValue] + ); + + runOnJS(onValueChange)(progress); + } + ); + + const rPickerStyle = useAnimatedStyle(() => { + return { + transform: [ + { translateX: translateX.value }, + { translateY: -pickerR + progressBarHeight }, + { scale: scale.value }, + ], + }; + }, []); + + const gesture = Gesture.Pan() + .onBegin(() => { + scale.value = withTiming(1.2); + contextX.value = translateX.value; + }) + .onUpdate((event) => { + clamp; + translateX.value = clamp( + contextX.value + event.translationX, + -pickerR, + sliderWidth - pickerR + ); + }) + .onFinalize(() => { + scale.value = withTiming(1); + }); + + return ( + + + + + + + ); +}; diff --git a/example/src/Examples/SpeedTest/SpeedTest.tsx b/example/src/Examples/SpeedTest/SpeedTest.tsx new file mode 100644 index 0000000000..85cf73abe8 --- /dev/null +++ b/example/src/Examples/SpeedTest/SpeedTest.tsx @@ -0,0 +1,352 @@ +/* eslint-disable react/no-unstable-nested-components */ +import { StyleSheet, View, useWindowDimensions } from "react-native"; +import React, { useMemo } from "react"; +import type { SkTextStyle } from "@shopify/react-native-skia"; +import { + Canvas, + Skia, + Group, + Circle, + vec, + Path, + LinearGradient, + Paint, + BlurMask, + useFonts, + Paragraph, + TextAlign, + interpolate, + usePathValue, +} from "@shopify/react-native-skia"; +import { useDerivedValue, useSharedValue } from "react-native-reanimated"; + +import { Header } from "./Header"; +import { Slider } from "./Slider"; + +const ticks = [0, 1, 5, 10, 20, 30, 50, 75, 100]; + +interface TickProps { + value: number; + x: number; + y: number; +} + +export const SpeedTest = () => { + const { width, height } = useWindowDimensions(); + const speed = useSharedValue(0); + + // For "native" SpeedTest experience consider using Gauge-Regular and Montserrat-Bold + const customFontMgr = useFonts({ + SfPro: [require("../../assets/SF-Pro-Display-Bold.otf")], + }); + + const paragraphBuilder = useMemo(() => { + const paragraphStyle = { + textAlign: TextAlign.Center, + }; + + if (!customFontMgr) { + return Skia.ParagraphBuilder.Make(paragraphStyle); + } + + return Skia.ParagraphBuilder.Make(paragraphStyle, customFontMgr); + }, [customFontMgr]); + + const strokeWidth = 25; + const _r = width / 3 + 15; // Radius of the speedometer + const cx = width / 2; // Center x-coordinate + const cy = height / 2; // Center y-coordinate + + const startAngle = 135; + const endAngle = 405; + const arcAnglesDiff = endAngle - startAngle; + + const maxValue = 100; // Maximum value of the speedometer + + const backgroundColor = "rgba(15,15,28,1)"; + + const sweepAngle = useDerivedValue(() => { + const output = ticks.map( + (_, i, arr) => (i * arcAnglesDiff) / (arr.length - 1) + ); + + return interpolate(speed.value, ticks, output); + }); + + const ArcGradient = () => { + const x = cx - _r - strokeWidth / 2; + const _width = width - x * 2; + + return ( + + ); + }; + + const BackgroundArc = () => { + const backgroundArcPath = usePathValue((path) => { + "worklet"; + const r = _r; + + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + endAngle, + -(arcAnglesDiff - sweepAngle.value) + ); + return path; + }); + + const color = Skia.Color("rgb(26,33,61)"); + + return ( + + ); + }; + + const ActiveArc = () => { + const activePath = usePathValue((path) => { + "worklet"; + const r = _r; + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + startAngle, + sweepAngle.value + ); + + return path; + }); + + const shadowPath = usePathValue((path) => { + "worklet"; + const r = _r - strokeWidth / 2; + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + startAngle, + sweepAngle.value + ); + + return path; + }); + + return ( + + + + + + + + ); + }; + + const NegativePath = () => { + const color = backgroundColor; + + const negativeArcPath = usePathValue((path) => { + "worklet"; + const r = _r - strokeWidth; + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + startAngle, + -(360 - sweepAngle.value) + ); + + return path; + }); + + const circleR = _r + strokeWidth / 2; + + return ( + + + + + + + ); + }; + + const Needle = () => { + const topWidth = 10; + const bottomWidth = topWidth / 2; + const needleHeight = 100; + + const needlePath = usePathValue((path) => { + "worklet"; + + path.moveTo(cx - topWidth, cy); + path.lineTo(cx + topWidth, cy); + path.lineTo(cx + bottomWidth, cy - needleHeight); + path.lineTo(cx - bottomWidth, cy - needleHeight); + + return path; + }); + + const transform = useDerivedValue(() => { + const startAngleRadians = startAngle * (Math.PI / 180); + const currentAngleRadians = + (sweepAngle.value + startAngle) * (Math.PI / 180); + const rotate = currentAngleRadians - startAngleRadians * 2; + + return [ + { + rotate, + }, + ]; + }); + + return ( + + + + + + ); + }; + + const SpeedLabel = () => { + const textStyle: SkTextStyle = { + color: Skia.Color("white"), + fontFamilies: ["SfPro"], + fontSize: 42, + }; + + const paragraph = useDerivedValue(() => { + const text = `${speed.value.toFixed(2).toString()}`; + + paragraphBuilder.reset(); + + return paragraphBuilder.pushStyle(textStyle).addText(text).build(); + }); + + const r = (_r * 0.75) / 2; + const y = cy + r + 20; + + return ; + }; + + const Ticks = () => { + const end = 1; + const start = -Math.PI - end; + const angleDiff = end - start; + const singleTickAngle = angleDiff / ticks.length; + const r = _r - strokeWidth - 5; + + return ticks.map((value, i) => { + const angle = + interpolate(i, [0, ticks.length], [start, end]) + singleTickAngle / 2; + + const x = cx - 20 + r * Math.cos(angle); + const y = cy - 10 + r * Math.sin(angle); + + return ; + }); + }; + + const Tick: React.FC = ({ value, x, y }) => { + const alpha = useSharedValue(0.5); + + const paragraph = useDerivedValue(() => { + alpha.value = interpolate( + speed.value, + [0, value - 10, value], + [0.5, 0.5, 1] + ); + + const textStyle = { + color: Skia.Color(`rgba(255,255,255,${alpha.value})`), + fontFamilies: ["SfPro"], + fontSize: 16, + }; + + paragraphBuilder.reset(); + + return paragraphBuilder + .pushStyle(textStyle) + .addText(value.toString()) + .build(); + }, [customFontMgr]); + + return ; + }; + + const handleValueChange = (value: number) => { + speed.value = value; + }; + + return ( + +
+ + + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + sliderContainer: { + flex: 1, + alignItems: "center", + }, +}); diff --git a/example/src/Examples/SpeedTest/index.ts b/example/src/Examples/SpeedTest/index.ts new file mode 100644 index 0000000000..28e1054a12 --- /dev/null +++ b/example/src/Examples/SpeedTest/index.ts @@ -0,0 +1 @@ +export * from "./SpeedTest"; diff --git a/example/src/Examples/index.ts b/example/src/Examples/index.ts index 11da100182..9bce87b6d5 100644 --- a/example/src/Examples/index.ts +++ b/example/src/Examples/index.ts @@ -22,3 +22,4 @@ export * from "./Severance"; export * from "./Transitions"; export * from "./Stickers"; export * from "./FrostedCard"; +export * from "./SpeedTest"; diff --git a/example/src/Home/HomeScreen.tsx b/example/src/Home/HomeScreen.tsx index 5c12877aa4..8a4b636cb2 100644 --- a/example/src/Home/HomeScreen.tsx +++ b/example/src/Home/HomeScreen.tsx @@ -47,6 +47,11 @@ export const HomeScreen = () => { description="Aurora Design via Mesh Gradients" route="Aurora" /> + = { Transitions: "transitions", Stickers: "stickers", FrostedCard: "frosted-card", + SpeedTest: "speedtest", }, }, prefixes: ["rnskia://"], @@ -146,6 +148,13 @@ const App = () => { header: () => null, }} /> + null, + }} + /> { + const { goBack } = useNavigation(); + const insets = useSafeAreaInsets(); + + const styles = StyleSheet.create({ + container: { + position: "absolute", + top: insets.top, + left: insets.left, + zIndex: 1, + }, + }); + + return ( + + + + + + + + + + ); +}; diff --git a/fabricexample/src/Examples/SpeedTest/Slider.tsx b/fabricexample/src/Examples/SpeedTest/Slider.tsx new file mode 100644 index 0000000000..9e1e1afc49 --- /dev/null +++ b/fabricexample/src/Examples/SpeedTest/Slider.tsx @@ -0,0 +1,104 @@ +import { StyleSheet, View, useWindowDimensions } from "react-native"; +import React from "react"; +import Animated, { + interpolate, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; + +interface Props { + onValueChange: (value: number) => void; + minValue: number; + maxValue: number; +} + +const size = 32; + +const clamp = (value: number, lowerBound: number, upperBound: number) => { + "worklet"; + return Math.min(Math.max(value, lowerBound), upperBound); +}; + +export const Slider: React.FC = ({ + onValueChange, + minValue, + maxValue, +}) => { + const { width } = useWindowDimensions(); + + const sliderWidth = width / 2; + const pickerR = size / 2; + const progressBarHeight = 3; + + const translateX = useSharedValue(-pickerR); + const contextX = useSharedValue(0); + const scale = useSharedValue(1); + + const styles = StyleSheet.create({ + picker: { + position: "absolute", + width: size, + height: size, + borderRadius: pickerR, + backgroundColor: "white", + }, + progressBar: { + height: progressBarHeight, + backgroundColor: "rgba(255,255,255,0.5)", + width: sliderWidth, + }, + }); + + useAnimatedReaction( + () => translateX.value, + (value) => { + const progress = interpolate( + value, + [-pickerR, sliderWidth - pickerR], + [minValue, maxValue] + ); + + runOnJS(onValueChange)(progress); + } + ); + + const rPickerStyle = useAnimatedStyle(() => { + return { + transform: [ + { translateX: translateX.value }, + { translateY: -pickerR + progressBarHeight }, + { scale: scale.value }, + ], + }; + }, []); + + const gesture = Gesture.Pan() + .onBegin(() => { + scale.value = withTiming(1.2); + contextX.value = translateX.value; + }) + .onUpdate((event) => { + clamp; + translateX.value = clamp( + contextX.value + event.translationX, + -pickerR, + sliderWidth - pickerR + ); + }) + .onFinalize(() => { + scale.value = withTiming(1); + }); + + return ( + + + + + + + ); +}; diff --git a/fabricexample/src/Examples/SpeedTest/SpeedTest.tsx b/fabricexample/src/Examples/SpeedTest/SpeedTest.tsx new file mode 100644 index 0000000000..85cf73abe8 --- /dev/null +++ b/fabricexample/src/Examples/SpeedTest/SpeedTest.tsx @@ -0,0 +1,352 @@ +/* eslint-disable react/no-unstable-nested-components */ +import { StyleSheet, View, useWindowDimensions } from "react-native"; +import React, { useMemo } from "react"; +import type { SkTextStyle } from "@shopify/react-native-skia"; +import { + Canvas, + Skia, + Group, + Circle, + vec, + Path, + LinearGradient, + Paint, + BlurMask, + useFonts, + Paragraph, + TextAlign, + interpolate, + usePathValue, +} from "@shopify/react-native-skia"; +import { useDerivedValue, useSharedValue } from "react-native-reanimated"; + +import { Header } from "./Header"; +import { Slider } from "./Slider"; + +const ticks = [0, 1, 5, 10, 20, 30, 50, 75, 100]; + +interface TickProps { + value: number; + x: number; + y: number; +} + +export const SpeedTest = () => { + const { width, height } = useWindowDimensions(); + const speed = useSharedValue(0); + + // For "native" SpeedTest experience consider using Gauge-Regular and Montserrat-Bold + const customFontMgr = useFonts({ + SfPro: [require("../../assets/SF-Pro-Display-Bold.otf")], + }); + + const paragraphBuilder = useMemo(() => { + const paragraphStyle = { + textAlign: TextAlign.Center, + }; + + if (!customFontMgr) { + return Skia.ParagraphBuilder.Make(paragraphStyle); + } + + return Skia.ParagraphBuilder.Make(paragraphStyle, customFontMgr); + }, [customFontMgr]); + + const strokeWidth = 25; + const _r = width / 3 + 15; // Radius of the speedometer + const cx = width / 2; // Center x-coordinate + const cy = height / 2; // Center y-coordinate + + const startAngle = 135; + const endAngle = 405; + const arcAnglesDiff = endAngle - startAngle; + + const maxValue = 100; // Maximum value of the speedometer + + const backgroundColor = "rgba(15,15,28,1)"; + + const sweepAngle = useDerivedValue(() => { + const output = ticks.map( + (_, i, arr) => (i * arcAnglesDiff) / (arr.length - 1) + ); + + return interpolate(speed.value, ticks, output); + }); + + const ArcGradient = () => { + const x = cx - _r - strokeWidth / 2; + const _width = width - x * 2; + + return ( + + ); + }; + + const BackgroundArc = () => { + const backgroundArcPath = usePathValue((path) => { + "worklet"; + const r = _r; + + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + endAngle, + -(arcAnglesDiff - sweepAngle.value) + ); + return path; + }); + + const color = Skia.Color("rgb(26,33,61)"); + + return ( + + ); + }; + + const ActiveArc = () => { + const activePath = usePathValue((path) => { + "worklet"; + const r = _r; + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + startAngle, + sweepAngle.value + ); + + return path; + }); + + const shadowPath = usePathValue((path) => { + "worklet"; + const r = _r - strokeWidth / 2; + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + startAngle, + sweepAngle.value + ); + + return path; + }); + + return ( + + + + + + + + ); + }; + + const NegativePath = () => { + const color = backgroundColor; + + const negativeArcPath = usePathValue((path) => { + "worklet"; + const r = _r - strokeWidth; + path.addArc( + { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + }, + startAngle, + -(360 - sweepAngle.value) + ); + + return path; + }); + + const circleR = _r + strokeWidth / 2; + + return ( + + + + + + + ); + }; + + const Needle = () => { + const topWidth = 10; + const bottomWidth = topWidth / 2; + const needleHeight = 100; + + const needlePath = usePathValue((path) => { + "worklet"; + + path.moveTo(cx - topWidth, cy); + path.lineTo(cx + topWidth, cy); + path.lineTo(cx + bottomWidth, cy - needleHeight); + path.lineTo(cx - bottomWidth, cy - needleHeight); + + return path; + }); + + const transform = useDerivedValue(() => { + const startAngleRadians = startAngle * (Math.PI / 180); + const currentAngleRadians = + (sweepAngle.value + startAngle) * (Math.PI / 180); + const rotate = currentAngleRadians - startAngleRadians * 2; + + return [ + { + rotate, + }, + ]; + }); + + return ( + + + + + + ); + }; + + const SpeedLabel = () => { + const textStyle: SkTextStyle = { + color: Skia.Color("white"), + fontFamilies: ["SfPro"], + fontSize: 42, + }; + + const paragraph = useDerivedValue(() => { + const text = `${speed.value.toFixed(2).toString()}`; + + paragraphBuilder.reset(); + + return paragraphBuilder.pushStyle(textStyle).addText(text).build(); + }); + + const r = (_r * 0.75) / 2; + const y = cy + r + 20; + + return ; + }; + + const Ticks = () => { + const end = 1; + const start = -Math.PI - end; + const angleDiff = end - start; + const singleTickAngle = angleDiff / ticks.length; + const r = _r - strokeWidth - 5; + + return ticks.map((value, i) => { + const angle = + interpolate(i, [0, ticks.length], [start, end]) + singleTickAngle / 2; + + const x = cx - 20 + r * Math.cos(angle); + const y = cy - 10 + r * Math.sin(angle); + + return ; + }); + }; + + const Tick: React.FC = ({ value, x, y }) => { + const alpha = useSharedValue(0.5); + + const paragraph = useDerivedValue(() => { + alpha.value = interpolate( + speed.value, + [0, value - 10, value], + [0.5, 0.5, 1] + ); + + const textStyle = { + color: Skia.Color(`rgba(255,255,255,${alpha.value})`), + fontFamilies: ["SfPro"], + fontSize: 16, + }; + + paragraphBuilder.reset(); + + return paragraphBuilder + .pushStyle(textStyle) + .addText(value.toString()) + .build(); + }, [customFontMgr]); + + return ; + }; + + const handleValueChange = (value: number) => { + speed.value = value; + }; + + return ( + +
+ + + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + sliderContainer: { + flex: 1, + alignItems: "center", + }, +}); diff --git a/fabricexample/src/Examples/SpeedTest/index.ts b/fabricexample/src/Examples/SpeedTest/index.ts new file mode 100644 index 0000000000..28e1054a12 --- /dev/null +++ b/fabricexample/src/Examples/SpeedTest/index.ts @@ -0,0 +1 @@ +export * from "./SpeedTest"; diff --git a/fabricexample/src/Examples/index.ts b/fabricexample/src/Examples/index.ts index 11da100182..9bce87b6d5 100644 --- a/fabricexample/src/Examples/index.ts +++ b/fabricexample/src/Examples/index.ts @@ -22,3 +22,4 @@ export * from "./Severance"; export * from "./Transitions"; export * from "./Stickers"; export * from "./FrostedCard"; +export * from "./SpeedTest"; diff --git a/fabricexample/src/Home/HomeScreen.tsx b/fabricexample/src/Home/HomeScreen.tsx index 5c12877aa4..8a4b636cb2 100644 --- a/fabricexample/src/Home/HomeScreen.tsx +++ b/fabricexample/src/Home/HomeScreen.tsx @@ -47,6 +47,11 @@ export const HomeScreen = () => { description="Aurora Design via Mesh Gradients" route="Aurora" /> +