diff --git a/img/dynamicCardRotation/driver-license-background.png b/img/dynamicCardRotation/driver-license-background.png new file mode 100644 index 00000000000..7dfe0b1aaef Binary files /dev/null and b/img/dynamicCardRotation/driver-license-background.png differ diff --git a/ts/features/design-system/core/DSDynamicCardRotation.tsx b/ts/features/design-system/core/DSDynamicCardRotation.tsx new file mode 100644 index 00000000000..0532ceacfe8 --- /dev/null +++ b/ts/features/design-system/core/DSDynamicCardRotation.tsx @@ -0,0 +1,429 @@ +/* eslint-disable functional/immutable-data */ +import { H6, IOColors, VSpacer, hexToRgba } from "@pagopa/io-app-design-system"; +import { + Canvas, + Color, + DiffRect, + Image, + LinearGradient, + Mask, + RoundedRect, + Circle as SkiaCircle, + Group as SkiaGroup, + RadialGradient as SkiaRadialGradient, + rect, + rrect, + useImage, + vec +} from "@shopify/react-native-skia"; +import * as React from "react"; +import { useState } from "react"; +import { + ColorValue, + LayoutChangeEvent, + LayoutRectangle, + StyleSheet, + Text, + View, + ViewStyle +} from "react-native"; +import Animated, { + Extrapolation, + SensorType, + interpolate, + useAnimatedReaction, + useAnimatedSensor, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring +} from "react-native-reanimated"; +import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; + +type CardSize = { + width: LayoutRectangle["width"]; + height: LayoutRectangle["height"]; +}; + +type LightSize = { + value: LayoutRectangle["width"]; +}; + +/* LIGHT + Visual parameters */ +const lightSizePercentage: ViewStyle["width"] = "90%"; +const lightScaleMultiplier: number = 1; +const lightOpacity: ViewStyle["opacity"] = 0.9; +const lightSkiaOpacity: number = 0.4; +/* Percentage of visible light when it's near +card boundaries */ +const visibleLightPercentage: number = 0.25; + +/* CARD + Visual parameters */ +const cardAspectRatio: ViewStyle["aspectRatio"] = 7 / 4; +const cardBorderRadius: number = 24; +const cardBorderWidth: number = 1; +const cardBorderColor: ColorValue = IOColors["hanPurple-500"]; +const cardBorderHighlighted: ColorValue = IOColors.white; +const cardBorderOpacity: number = 0.65; +// Drivers' License +const cardGradient: Array = ["#F4ACD5", "#FCE6F2"]; +// Flag +// const flagDistanceFromEdge: number = 16; +// const flagSize: number = 32; + +/* MOVEMENT + Spring config for the light movement */ +const springConfig = { + mass: 1, + damping: 50, + stiffness: 200, + overshootClamping: false +}; + +export const DSDynamicCardRotation = () => { + /* On first render, store the current device orientation + using quaternions */ + const rotationSensor = useAnimatedSensor(SensorType.ROTATION); + const { roll: initialRoll, pitch: initialPitch } = + rotationSensor.sensor.value; + + const roll = useSharedValue(0); + const pitch = useSharedValue(0); + const skiaTranslateX = useSharedValue(0); + const skiaTranslateY = useSharedValue(0); + + useAnimatedReaction( + () => rotationSensor.sensor.value, + sensor => { + roll.value = sensor.roll; + pitch.value = sensor.pitch; + }, + [] + ); + /* Not all devices are in an initial flat position on a surface + (e.g. a table) then we use relative rotation values, + not absolute ones */ + const relativeRoll = useDerivedValue(() => -(initialRoll - roll.value)); + const relativePitch = useDerivedValue(() => initialPitch - pitch.value); + + // eslint-disable-next-line no-console + console.log("Sensor values:", `qx: ${roll.value}, qy: ${pitch.value}`); + + /* Get both card and light sizes to set the basic boundaries */ + const [cardSize, setCardSize] = useState(); + const [lightSize, setLightSize] = useState(); + + const getCardSize = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setCardSize({ width, height }); + }; + + const getLightSize = (event: LayoutChangeEvent) => { + const { width: newLightSize } = event.nativeEvent.layout; + setLightSize({ value: newLightSize }); + }; + + /* Set translate boundaries */ + const maxTranslateX = + ((cardSize?.width ?? 0) - + (lightSize?.value ?? 0) * visibleLightPercentage) / + 2; + + const maxTranslateY = + ((cardSize?.height ?? 0) - + (lightSize?.value ?? 0) * visibleLightPercentage) / + 2; + + /* We don't need to consider the whole + sensor range, just the 1/10 */ + const sensorRange: number = 0.1; + + /* Calculate the light position using quaternions */ + const lightAnimatedStyle = useAnimatedStyle(() => { + const translateX = interpolate( + relativeRoll.value, + [-sensorRange, sensorRange], + [maxTranslateX, -maxTranslateX], + Extrapolation.CLAMP + ); + + const translateY = interpolate( + relativePitch.value, + [-sensorRange, sensorRange], + [-maxTranslateY, maxTranslateY], + Extrapolation.CLAMP + ); + + return { + transform: [ + { translateX: withSpring(translateX, springConfig) }, + { translateY: withSpring(translateY, springConfig) }, + { scale: lightScaleMultiplier } + ] + }; + }); + + const skiaLightTranslateValues = useDerivedValue(() => { + skiaTranslateX.value = withSpring( + interpolate( + relativeRoll.value, + [-sensorRange, sensorRange], + [maxTranslateX, -maxTranslateX], + Extrapolation.CLAMP + ), + springConfig + ); + + skiaTranslateY.value = withSpring( + interpolate( + relativePitch.value, + [-sensorRange, sensorRange], + [-maxTranslateY, maxTranslateY], + Extrapolation.CLAMP + ), + springConfig + ); + + return [ + { translateX: skiaTranslateX.value }, + { translateY: skiaTranslateY.value }, + { scale: lightScaleMultiplier } + ]; + }); + + // Inner card (border excluded) + const CardInnerMask = () => ( + + ); + + const CardLight = () => ( + + + + + + ); + + const CardBorder = ({ + color = cardBorderColor, + opacity = cardBorderOpacity + }: { + color?: Color; + opacity?: number; + }) => { + const outerRect = rrect( + rect(0, 0, cardSize?.width ?? 0, cardSize?.height ?? 0), + cardBorderRadius, + cardBorderRadius + ); + + const innerRect = rrect( + rect( + cardBorderWidth, + cardBorderWidth, + (cardSize?.width ?? 0) - cardBorderWidth * 2, + (cardSize?.height ?? 0) - cardBorderWidth * 2 + ), + cardBorderRadius - cardBorderWidth, + cardBorderRadius - cardBorderWidth + ); + + return ( + + ); + }; + + const CardPatternMask = () => { + const cardPattern = useImage( + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("../../../../img/dynamicCardRotation/driver-license-background.png") + ); + + return ( + }> + {/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */} + + + ); + }; + + const CardBorderMask = () => ( + }> + + + ); + + return ( + + + + + + + {/* There are many stops because it's an easing gradient. + To learn more: https://larsenwork.com/easing-gradients/ */} + + + + + + + + + + + + + + + + + + + + + + + Using React Native engine + + + + + + + + + + + {/* */} + + + + Using Skia engine + + +
Card
+ {`Size: ${cardSize?.width} × ${cardSize?.height}`} + +
Light (Circle)
+ {`Size: ${lightSize?.value}`} +
+
+ ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + paddingTop: 24, + paddingHorizontal: 24 + }, + + light: { + alignSelf: "center", + width: lightSizePercentage, + aspectRatio: 1, + opacity: lightOpacity, + borderRadius: 400 + }, + cardDebugLabel: { + fontSize: 11, + marginTop: 4 + }, + box: { + justifyContent: "center", + width: "100%", + aspectRatio: cardAspectRatio, + borderRadius: 24, + borderCurve: "continuous", + backgroundColor: IOColors["hanPurple-250"] + }, + debugInfo: { + alignSelf: "flex-start", + position: "relative", + top: 16 + } +}); diff --git a/ts/features/design-system/navigation/navigator.tsx b/ts/features/design-system/navigation/navigator.tsx index 46cd117c8ca..cd15ac8b7f0 100644 --- a/ts/features/design-system/navigation/navigator.tsx +++ b/ts/features/design-system/navigation/navigator.tsx @@ -68,6 +68,7 @@ import { DSTextFields } from "../core/DSTextFields"; import { DSToastNotifications } from "../core/DSToastNotifications"; import { DSTypography } from "../core/DSTypography"; import { DSWallet } from "../core/DSWallet"; +import { DSDynamicCardRotation } from "../core/DSDynamicCardRotation"; import { DesignSystemParamsList } from "./params"; import DESIGN_SYSTEM_ROUTES from "./routes"; @@ -365,6 +366,17 @@ export const DesignSystemNavigator = () => { }} /> + +