Matched Geometry

Tap to expand matched geometry transition

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-worklets expo-blur react-native-gesture-handler

Copy and paste the following code into your project. component/organisms/matched-geometry

import React, { useRef, useState, useCallback, memo } from "react";import {  View,  StyleSheet,  Pressable,  Modal,  type ViewStyle,} from "react-native";import type { LayoutMeasurement, IMatchedGeometry } from "./types";import Animated, {  useSharedValue,  useAnimatedStyle,  withTiming,  Easing,} from "react-native-reanimated";import { Gesture, GestureDetector } from "react-native-gesture-handler";import { BlurView } from "expo-blur";import { scheduleOnRN } from "react-native-worklets";import {  ANIMATION_CONFIG,  DISMISS_THRESHOLD,  SCREEN_HEIGHT,  SCREEN_WIDTH,} from "./conf";export const MatchedGeometry: React.FC<IMatchedGeometry> &  React.FunctionComponent<IMatchedGeometry> = memo<IMatchedGeometry>(  ({    id,    children,    onPress,  }: IMatchedGeometry):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const [isExpanded, setIsExpanded] = useState<boolean>(false);    const [isAnimating, setIsAnimating] = useState<boolean>(false);    const viewRef = useRef<View>(null);    const layoutRef = useRef<LayoutMeasurement | null>(null);    const [layoutReady, setLayoutReady] = useState<boolean>(false);    const translateX = useSharedValue<number>(0);    const translateY = useSharedValue<number>(0);    const scale = useSharedValue<number>(1);    const opacity = useSharedValue<number>(0);    const gestureTranslateX = useSharedValue<number>(0);    const gestureTranslateY = useSharedValue<number>(0);    const sourceOpacity = useSharedValue<number>(1);    const contentOpacity = useSharedValue<number>(0);    const layoutX = useSharedValue<number>(0);    const layoutY = useSharedValue<number>(0);    const layoutWidth = useSharedValue<number>(0);    const layoutHeight = useSharedValue<number>(0);    const resetAnimatedValues = useCallback(() => {      "worklet";      translateX.value = 0;      translateY.value = 0;      scale.value = 1;      opacity.value = 0;      gestureTranslateX.value = 0;      gestureTranslateY.value = 0;      contentOpacity.value = 0;    }, []);    const handlePress = useCallback(() => {      if (isAnimating || isExpanded) return;      setIsAnimating(true);      resetAnimatedValues();      if (viewRef.current) {        viewRef.current.measure((x, y, width, height, pageX, pageY) => {          layoutRef.current = { x, y, width, height, pageX, pageY };          layoutX.value = pageX;          layoutY.value = pageY;          layoutWidth.value = width;          layoutHeight.value = height;          requestAnimationFrame(() => {            setLayoutReady(true);            setIsExpanded(true);          });        });      }      sourceOpacity.value = withTiming<number>(0, { duration: 300 });      onPress?.();    }, [isAnimating, isExpanded, sourceOpacity, onPress]);    const animateToCenter = useCallback(() => {      const layout = layoutRef.current;      if (!layout) return;      const centerX = SCREEN_WIDTH / 2;      const centerY = SCREEN_HEIGHT / 2;      const currentCenterX = layout.pageX + layout.width / 2;      const currentCenterY = layout.pageY + layout.height / 2;      const deltaX = centerX - currentCenterX;      const deltaY = centerY - currentCenterY;      const targetScale = Math.min(        (SCREEN_WIDTH * 0.8) / layout.width,        (SCREEN_HEIGHT * 0.6) / layout.height,      );      contentOpacity.value = withTiming<number>(1, { duration: 200 });      opacity.value = withTiming<number>(1, ANIMATION_CONFIG);      translateX.value = withTiming<number>(deltaX, ANIMATION_CONFIG);      translateY.value = withTiming<number>(deltaY, ANIMATION_CONFIG);      scale.value = withTiming<number>(targetScale, ANIMATION_CONFIG, () => {        scheduleOnRN<[boolean], void>(setIsAnimating, false);      });    }, [translateX, translateY, scale, opacity, contentOpacity]);    const animateToOriginal = useCallback(() => {      setIsAnimating(true);      gestureTranslateX.value = withTiming<number>(0, ANIMATION_CONFIG);      gestureTranslateY.value = withTiming<number>(0, ANIMATION_CONFIG);      opacity.value = withTiming<number>(0, ANIMATION_CONFIG);      translateX.value = withTiming<number>(0, ANIMATION_CONFIG);      translateY.value = withTiming<number>(0, ANIMATION_CONFIG);      scale.value = withTiming<number>(1, ANIMATION_CONFIG, (finished) => {        if (finished) {          contentOpacity.value = 0;          scheduleOnRN(setIsExpanded, false);          scheduleOnRN(setIsAnimating, false);          scheduleOnRN(setLayoutReady, false);        }      });      sourceOpacity.value = withTiming<number>(1, {        duration: 500,        easing: Easing.bezier(0.33, 0.01, 0, 1),      });    }, [      translateX,      translateY,      scale,      opacity,      gestureTranslateX,      gestureTranslateY,      sourceOpacity,      contentOpacity,    ]);    const panGesture = Gesture.Pan()      .onUpdate((event) => {        gestureTranslateX.value = event.translationX;        gestureTranslateY.value = event.translationY;        const distance = Math.sqrt(          Math.pow(event.translationX, 2) + Math.pow(event.translationY, 2),        );        const maxDistance = 300;        const opacityFactor = Math.max(0.3, 1 - distance / maxDistance);        opacity.value = opacityFactor;      })      .onEnd((event) => {        const distance = Math.sqrt(          Math.pow(event.translationX, 2) + Math.pow(event.translationY, 2),        );        if (distance > DISMISS_THRESHOLD) {          scheduleOnRN(animateToOriginal);        } else {          gestureTranslateX.value = withTiming<number>(0, ANIMATION_CONFIG);          gestureTranslateY.value = withTiming<number>(0, ANIMATION_CONFIG);          opacity.value = withTiming<number>(1, ANIMATION_CONFIG);        }      });    React.useEffect(() => {      if (isExpanded && layoutReady) {        animateToCenter();      }    }, [isExpanded, layoutReady, animateToCenter]);    const handleLayout = useCallback(() => {      if (viewRef.current) {        viewRef.current.measure((x, y, width, height, pageX, pageY) => {          layoutRef.current = { x, y, width, height, pageX, pageY };        });      }    }, []);    const animatedModalStyle = useAnimatedStyle<      Pick<        ViewStyle,        | "position"        | "left"        | "top"        | "width"        | "height"        | "opacity"        | "transform"      >    >(() => {      return {        position: "absolute",        left: layoutX.value,        top: layoutY.value,        width: layoutWidth.value,        height: layoutHeight.value,        opacity: contentOpacity.value,        transform: [          { translateX: translateX.value + gestureTranslateX.value },          { translateY: translateY.value + gestureTranslateY.value },          { scale: scale.value },        ],      };    });    const backdropStyle = useAnimatedStyle<Pick<ViewStyle, "opacity">>(() => ({      opacity: opacity.value,    }));    const sourceStyle = useAnimatedStyle<Pick<ViewStyle, "opacity">>(() => ({      opacity: sourceOpacity.value,    }));    return (      <>        <Pressable onPress={handlePress} key={id}>          <Animated.View            ref={viewRef}            onLayout={handleLayout}            collapsable={false}            style={sourceStyle}          >            {children}          </Animated.View>        </Pressable>        <Modal          transparent          visible={isExpanded}          onRequestClose={animateToOriginal}        >          <GestureDetector gesture={panGesture}>            <View style={styles.modalContainer}>              <View style={StyleSheet.absoluteFill} pointerEvents="box-none">                <Pressable                  style={StyleSheet.absoluteFill}                  onPress={animateToOriginal}                >                  <Animated.View                    style={[StyleSheet.absoluteFill, backdropStyle]}                  >                    <BlurView                      experimentalBlurMethod="dimezisBlurView"                      intensity={50}                      tint="default"                      style={[                        StyleSheet.absoluteFill,                        {                          overflow: "hidden",                        },                      ]}                    />                  </Animated.View>                </Pressable>              </View>              <Animated.View style={animatedModalStyle} pointerEvents="auto">                {children}              </Animated.View>            </View>          </GestureDetector>        </Modal>      </>    );  },);const styles = StyleSheet.create({  modalContainer: {    flex: 1,  },});

Usage

import { StyleSheet, Image, View, Text } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { MatchedGeometry } from "@/components/organisms/matched-geometry";import { useFonts } from "expo-font";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={styles.stage}>        <View style={styles.card}>          <MatchedGeometry id="swan">            <Image              source={{                uri: "https://i.pinimg.com/1200x/c6/ce/40/c6ce40ffd43ad9a532a39951c6abfad6.jpg",              }}              style={styles.image}            />          </MatchedGeometry>          <View style={styles.meta}>            <Text              style={[                styles.title,                { fontFamily: fontLoaded ? "SfProRounded" : undefined },              ]}            >              Mute Swan            </Text>            <Text              style={[                styles.subtitle,                {                  fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined,                },              ]}            >              Tap to expand            </Text>          </View>        </View>      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",  },  stage: {    flex: 1,    justifyContent: "center",    alignItems: "center",  },  card: {    width: 200,    borderRadius: 18,    backgroundColor: "rgba(255,255,255,0.06)",    overflow: "hidden",    bottom: 200,    right: 100,  },  image: {    width: "100%",    height: 200,    borderRadius: 18,  },  meta: {    padding: 12,  },  title: {    color: "#fff",    fontSize: 15,  },  subtitle: {    color: "rgba(255,255,255,0.6)",    fontSize: 12,  },});

Props

React Native Reanimated
React Native Worklets
Expo Blur
React Native Gesture Handler