React跨路由組件動畫

来源:https://www.cnblogs.com/dtux/archive/2023/10/11/17756569.html
-Advertisement-
Play Games

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:佳嵐 回顧傳統React動畫 對於普通的 React 動畫,我們大多使用官方推薦的 react-transition-group,其提供了四個基本組件 Tra ...


我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。

本文作者:佳嵐

回顧傳統React動畫

對於普通的 React 動畫,我們大多使用官方推薦的 react-transition-group,其提供了四個基本組件 Transition、CSSTransition、SwitchTransition、TransitionGroup

Transition

Transition 組件允許您使用簡單的聲明式 API 來描述組件的狀態變化,預設情況下,Transition 組件不會改變它呈現的組件的行為,它只跟蹤組件的“進入”和“退出”狀態,我們需要做的是賦予這些狀態意義。

其一共提供了四種狀態,當組件感知到 in prop 變化時就會進行相應的狀態過渡

  • 'entering'
  • 'entered'
  • 'exiting'
  • 'exited'
const defaultStyle = {
  transition: `opacity ${duration}ms ease-in-out`,
  opacity: 0,
}

const transitionStyles = {
  entering: { opacity: 1 },
  entered:  { opacity: 1 },
  exiting:  { opacity: 0 },
  exited:  { opacity: 0 },
};

const Fade = ({ in: inProp }) => (
  <Transition in={inProp} timeout={duration}>
    {state => (
      <div style={{
        ...defaultStyle,
        ...transitionStyles[state]
      }}>
        I'm a fade Transition!
      </div>
    )}
  </Transition>
);

CSSTransition

此組件主要用來做 CSS 樣式過渡,它能夠在組件各個狀態變化的時候給我們要過渡的標簽添加上不同的類名。所以參數和平時的 className 不同,參數為:classNames

<CSSTransition
  in={inProp}
  timeout={300}
  classNames="fade"
  unmountOnExit
>
  <div className="star">⭐</div>
</CSSTransition>

// 定義過渡樣式類
.fade-enter {
  opacity: 0;
}
.fade-enter-active {
  opacity: 1;
  transition: opacity 200ms;
}
.fade-exit {
  opacity: 1;
}
.fade-exit-active {
  opacity: 0;
  transition: opacity 200ms;
}

SwitchTransition

SwitchTransition 用來做組件切換時的過渡,其會緩存傳入的 children,併在過渡結束後渲染新的 children

function App() {
 const [state, setState] = useState(false);
 return (
   <SwitchTransition>
     <CSSTransition
       key={state ? "Goodbye, world!" : "Hello, world!"}
       classNames='fade'
     >
       <button onClick={() => setState(state => !state)}>
         {state ? "Goodbye, world!" : "Hello, world!"}
       </button>
     </CSSTransition>
   </SwitchTransition>
 );
}

TransitionGroup

如果有一組 CSSTransition 需要我們去過渡,那麼我們需要管理每一個 CSSTransition 的 in 狀態,這樣會很麻煩。

TransitionGroup 可以幫我們管理一組 Transition 或 CSSTransition 組件,為此我們不再需要給 Transition 組件傳入 in 屬性來標識過渡狀態,轉用 key 屬性來代替 in

<TransitionGroup>
  { 
      this.state.list.map((item, index) => {  
          return ( 
            <CSSTransition
                key = {item.id}
                timeout  = {1000}
                classNames = 'fade'
                unmountOnExit
              >
                <TodoItem />  
              </CSSTransition>
          )
        }
  }
</TransitionGroup>

TransitionGroup 會監測其 children 的變化,將新的 children 與原有的 children 使用 key 進行比較,就能得出哪些 children 是新增的與刪除的,從而為他們註入進場動畫或離場動畫。

file

FLIP 動畫

FLIP 是什麼?

FLIPFirstLastInvertPlay四個單詞首字母的縮寫

First, 元素過渡前開始位置信息

Last:執行一段代碼,使元素位置發生變化,記錄最後狀態的位置等信息.

Invert:根據 First 和 Last 的位置信息,計算出位置差值,使用 transform: translate(x,y) 將元素移動到First的位置。

Play:  給元素加上 transition 過渡屬性,再講 transform 置為 none,這時候因為 transition 的存在,開始播放絲滑的動畫。

Flip 動畫可以看成是一種編寫動畫的範式,方法論,對於開始或結束狀態未知的複雜動畫,可以使用 Flip 快速實現

位置過渡效果

file

代碼實現:

  const container = document.querySelector('.flip-container');
  const btnAdd = document.querySelector('#add-btn')
  const btnDelete = document.querySelector('#delete-btn')
  let rectList = []

  function addItem() {
    const el = document.createElement('div')
    el.className = 'flip-item'
    el.innerText = rectList.length + 1;
    el.style.width = (Math.random() * 300 + 100) + 'px'

    // 加入新元素前重新記錄起始位置信息
    recordFirst();

    // 加入新元素
    container.prepend(el)
    rectList.unshift({
      top: undefined,
      left: undefined
    })
    
    // 觸發FLIP
    update()
  }

  function removeItem() {
    const children = container.children;
    if (children.length > 0) {
      recordFirst();
      container.removeChild(children[0])
      rectList.shift()
      update()
    }
  }

  // 記錄位置
  function recordFirst() {
    const items = container.children;
    for (let i = 0; i < items.length; i++) {
      const rect = items[i].getBoundingClientRect();
      rectList[i] = {
        left: rect.left,
        top: rect.top
      }
    }
  }

  function update() {
    const items = container.children;
    for (let i = 0; i < items.length; i++) {
      // Last
      const rect = items[i].getBoundingClientRect();
      if (rectList[i].left !== undefined) {

       // Invert
        const transformX = rectList[i].left - rect.left;
        const transformY = rectList[i].top - rect.top;

        items[i].style.transform = `translate(${transformX}px, ${transformY}px)`
        items[i].style.transition = "none"

        // Play
        requestAnimationFrame(() => {
          items[i].style.transform = `none`
          items[i].style.transition = "all .5s"
        })
      }
    }
  }

  btnAdd.addEventListener('click', () => {
    addItem()
  })
  btnDelete.addEventListener('click', () => {
    removeItem()
  })

使用 flip 實現的動畫 demo

亂序動畫:

file

縮放動畫:

React跨路由組件動畫

在 React 中路由之前的切換動畫可以使用 react-transition-group 來實現,但對於不同路由上的組件如何做到動畫過渡是個很大的難題,目前社區中也沒有一個成熟的方案。

使用flip來實現

在路由 A 中組件的大小與位置狀態可以當成 First, 在路由 B 中組件的大小與位置狀態可以當成 Last

從路由 A 切換至路由B時,向 B 頁面傳遞 First 狀態,B 頁面中需要過渡的組件再進行 Flip 動畫。

為此我們可以抽象出一個組件來幫我們實現 Flip 動畫,並且能夠在切換路由時保存組件的狀態。

對需要進行過渡的組件進行包裹, 使用相同的 flipId 來標識他們需要在不同的路由中過渡。

<FlipRouteAnimate className="about-profile" flipId="avatar" animateStyle={{ borderRadius: "15px" }}>
  <img src={require("./touxiang.jpg")} alt="" />
</FlipRouteAnimate>

完整代碼:

import React, { createRef } from "react";
import withRouter from "./utils/withRouter";
class FlipRouteAnimate extends React.Component {
  constructor(props) {
    super(props);
    this.flipRef = createRef();
  }
  // 用來存放所有實例的rect
  static flipRectMap = new Map();
  componentDidMount() {
    const {
      flipId,
      location: { pathname },
      animateStyle: lastAnimateStyle,
    } = this.props;

    const lastEl = this.flipRef.current;

    // 沒有上一個路由中組件的rect,說明不用進行動畫過渡
    if (!FlipRouteAnimate.flipRectMap.has(flipId) || flipId === undefined) return;

    // 讀取緩存的rect
    const first = FlipRouteAnimate.flipRectMap.get(flipId);
    if (first.route === pathname) return;

    // 開始FLIP動畫
    const firstRect = first.rect;
    const lastRect = lastEl.getBoundingClientRect();

    const transformOffsetX = firstRect.left - lastRect.left;
    const transformOffsetY = firstRect.top - lastRect.top;

    const scaleRatioX = firstRect.width / lastRect.width;
    const scaleRatioY = firstRect.height / lastRect.height;

    lastEl.style.transform = `translate(${transformOffsetX}px, ${transformOffsetY}px) scale(${scaleRatioX}, ${scaleRatioY})`;
    lastEl.style.transformOrigin = "left top";

    for (const styleName in first.animateStyle) {
      lastEl.style[styleName] = first.animateStyle[styleName];
    }

    setTimeout(() => {
      lastEl.style.transition = "all 2s";
      lastEl.style.transform = `translate(0, 0) scale(1)`;
      // 可能有其他屬性也需要過渡
      for (const styleName in lastAnimateStyle) {
        lastEl.style[styleName] = lastAnimateStyle[styleName];
      }
    }, 0);
  }

  componentWillUnmount() {
    const {
      flipId,
      location: { pathname },
      animateStyle = {},
    } = this.props;
    const el = this.flipRef.current;
    // 組件卸載時保存自己的位置等狀態
    const rect = el.getBoundingClientRect();

    FlipRouteAnimate.flipRectMap.set(flipId, {
      // 當前路由路徑
      route: pathname,
      // 組件的大小位置
      rect: rect,
      // 其他需要過渡的樣式
      animateStyle,
    });
  }
  render() {
    return (
      <div
        className={this.props.className}
        style={{ display: "inline-block", ...this.props.style, ...this.props.animateStyle }}
        ref={this.flipRef}
      >
        {this.props.children}
      </div>
    );
  }
}

實現效果:

file

共用組件的方式實現

要想在不同的路由共用同一個組件實例,並不現實,樹形的 Dom 樹並不允許我們這麼做。

file

我們可以換個思路,把組件提取到路由容器的外部,然後通過某種方式將該組件與路由頁面相關聯。

file

我們將 Float 組件提升至根組件,然後在每個路由中使用 Proxy 組件進行占位,當路由切換時,每個 Proxy 將其位置信息與其他 props 傳遞給 Float 組件,Float 組件再根據接收到的狀態信息,將自己移動到對應位置。

我們先封裝一個 Proxy 組件,  使用 PubSub 發佈元信息。

// FloatProxy.tsx
const FloatProxy: React.FC<any> = (props: any) => {
  const el = useRef();

  // 保存代理元素引用,方便獲取元素的位置信息
  useEffect(() => {
    PubSub.publish("proxyElChange", el);
    return () => {
      PubSub.publish("proxyElChange", null);
    }
  }, []);

  useEffect(() => {
    PubSub.publish("metadataChange", props);
  }, [props]);

  const computedStyle = useMemo(() => {
    const propStyle = props.style || {};
    return {
      border: "dashed 1px #888",
      transition: "all .2s ease-in",
      ...propStyle,
    };
  }, [props.style]);

  return <div {...props} style={computedStyle} ref={el}></div>;
};

在路由中使用, 將樣式信息進行傳遞

class Bar extends React.Component {
  render() {
    return (
      <div className="container">
        <p>bar</p>
        <div style={{ marginTop: "140px" }}>
          <FloatProxy style={{ width: 120, height: 120, borderRadius: 15, overflow: "hidden" }} />
        </div>
      </div>
    );
  }
}

創建全局變數用於保存代理信息

// floatData.ts
type ProxyElType = {
  current: HTMLElement | null;
};
type MetaType = {
  attrs: any;
  props: any;
};

export const metadata: MetaType = {
  attrs: {
    hideComponent: true,
    left: 0,
    top: 0
  },
  props: {},
};

export const proxyEl: ProxyElType = {
  current: null,
};

創建一個FloatContainer容器組件,用於監聽代理數據的變化,  數據變動時驅動組件進行移動

import { metadata, proxyEl } from "./floatData";
class FloatContainer extends React.Component<any, any> {
  componentDidMount() {
    // 將代理組件上的props綁定到Float組件上
    PubSub.subscribe("metadataChange", (msg, props) => {
      metadata.props = props;
      this.forceUpdate();
    });

    // 切換路由後代理元素改變,保存代理元素的位置信息
    PubSub.subscribe("proxyElChange", (msg, el) => {
      if (!el) {
        metadata.attrs.hideComponent = true;
        // 在下一次tick再更新dom
        setTimeout(() => {
          this.forceUpdate();
        }, 0);
        return;
      } else {
        metadata.attrs.hideComponent = false;
      }
      proxyEl.current = el.current;
      const rect = proxyEl.current?.getBoundingClientRect()!;
      metadata.attrs.left = rect.left;
      metadata.attrs.top = rect.top
      this.forceUpdate();
    });
  }

  render() {
    const { timeout = 500 } = this.props;
    const wrapperStyle: React.CSSProperties = {
      position: "fixed",
      left: metadata.attrs.left,
      top: metadata.attrs.top,
      transition: `all ${timeout}ms ease-in`,
   		// 當前路由未註冊Proxy時進行隱藏
      display: metadata.attrs.hideComponent ? "none" : "block",
    };

    const propStyle = metadata.props.style || {};

    // 註入過渡樣式屬性
    const computedProps = {
      ...metadata.props,
      style: {
        transition: `all ${timeout}ms ease-in`,
        ...propStyle,
      },
    };
    console.log(metadata.attrs.hideComponent)

    return <div className="float-element" style={wrapperStyle}>{this.props.render(computedProps)} </div>;
  }
}

將組件提取到路由容器外部,並使用 FloatContainer 包裹

function App() {
  return (
    <BrowserRouter>
      <div className="App">
        <NavLink to={"/"}>/foo</NavLink>
        <NavLink to={"/bar"}>/bar</NavLink>
        <NavLink to={"/baz"}>/baz</NavLink>
        <FloatContainer render={(attrs: any) => <MyImage {...attrs}/>}></FloatContainer>
        <Routes>
          <Route path="/" element={<Foo />}></Route>
          <Route path="/bar" element={<Bar />}></Route>
          <Route path="/baz" element={<Baz />}></Route>
        </Routes>
      </div>
    </BrowserRouter>
  );
}

實現效果:

file

目前我們實現了一個單例的組件,我們將組件改造一下,讓其可以被覆用

首先我們將元數據更改為一個元數據 map,以 layoutId 為鍵,元數據為值

// floatData.tsx
type ProxyElType = {
  current: HTMLElement | null;
};
type MetaType = {
  attrs: {
    hideComponent: boolean,
    left: number,
    top: number
  };
  props: any;
};

type floatType = {
  metadata: MetaType,
  proxyEl: ProxyElType
}

export const metadata: MetaType = {
  attrs: {
    hideComponent: true,
    left: 0,
    top: 0
  },
  props: {},
};

export const proxyEl: ProxyElType = {
  current: null,
};

export const floatMap = new Map<string, floatType>()

在代理組件中傳遞layoutId 來通知註冊了相同layoutId的floatContainer做出相應變更

// FloatProxy.tsx 

// 保存代理元素引用,方便獲取元素的位置信息
  useEffect(() => {
    const float = floatMap.get(props.layoutId);
    if (float) {
      float.proxyEl.current = el.current;
    } else {
      floatMap.set(props.layoutId, {
        metadata: {
          attrs: {
            hideComponent: true,
            left: 0,
            top: 0,
          },
          props: {},
        },
        proxyEl: {
          current: el.current,
        },
      });
    }
    PubSub.publish("proxyElChange", props.layoutId);
    return () => {
      if (float) {
        float.proxyEl.current = null
        PubSub.publish("proxyElChange", props.layoutId);
      }
    };
  }, []);


// 在路由中使用
 <FloatProxy layoutId='layout1' style={{ width: 200, height: 200 }} />

在FloatContainer組件上也加上layoutId來標識同一組

// FloatContainer.tsx

// 監聽到自己同組的Proxy發送消息時進行rerender
PubSub.subscribe("metadataChange", (msg, layoutId) => {
    if (layoutId === this.props.layoutId) {
        this.forceUpdate();
    }
});

// 頁面中使用
 <FloatContainer layoutId='layout1' render={(attrs: any) => <MyImage imgSrc={img} {...attrs} />}></FloatContainer>

實現多組過渡的效果

file

最後

歡迎關註【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • NineData控制台的新手任務旨在幫助新用戶熟悉和掌握其各項功能,包括數據源創建、SQL開發、分鐘級數據恢復、不停機數據遷移和企業協同開發等。通過任務引導和實踐,可以顯著降低使用門檻,提升使用效率,保證數據安全。對於新用戶,玖章算術提供了完整的NineData新手任務實戰指南,覆蓋四大模塊的基礎操... ...
  • JS一些問題記錄 1.switch,break後只會退出switch本身用於防止穿透,外層比如for不會退出,ifbreak的話就會退出整個迴圈 2.三元運算符用於比較簡單的兩個東西之間的比較,也不能輸出列印出來,但是if雙分支就可以 3.同一個頁面兩個for,都用i不會衝突,是兩個局部變數不會影響 ...
  • 打包後的項目靜態資源無法使用,導致頁面空白 靜態資源無法使用,那就說明項目打包後,圖片和其他靜態資源文件相對路徑不對,此時找到config裡面的index.js,在build模塊下加入assetsPublicPath: './', 如下圖所示, 在History模式下配合使用nginx運行打包後的項 ...
  • # 浮動會帶來的影響 —— 會造成父標簽塌陷的問題 解決辦法: 方法一:自己加一個div,設置高度 方法二:利用clear屬性 #d1{ clear: left; /*該標簽的左邊(地面和空中)都不能有浮動的元素*/ } 方法三:使用通用方法 在寫HTML代碼前,先提前寫好處理浮動帶來的影響的css ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 在本文中,我們將探討如何使用 CSS 以最少的代碼創造出精美的 CSS 絲帶形狀,並最終實現下麵這個效果: 下麵我們使用html和css來實現這個效果。我們使用內容自適應方式佈局,不用擔心裡面的文字長度。本文介紹兩種絲帶:左側的絲帶稱為“ ...
  • 目錄Vue中的響應式對象獨立的響應式值計算變數監聽響應式變數setup方法 Vue中的響應式對象 Vue3允許在setup()中定義組件需要的數據和方法, 通過return在模板中可以直接使用 reactive方法 <body> <div id = "Application"> </div> <sc ...
  • Effect的概念起源 從輸入輸出的角度理解Effect https://link.excalidraw.com/p/readonly/KXAy7d2DlnkM8X1yps6L 編程中的Effect起源於函數式編程中純函數的概念 純函數是指在相同的輸入下,總是產生相同的輸出,並且沒有任何副作用(si ...
  • 移動互聯網風起雲涌的數十年來,App 似乎成為了企業與用戶打交道最“理所當然”的形式,更年輕一代的用戶甚至可能認為 App 就是一個“與生俱來”的事物,但隨著移動互聯網發展的高峰離去,App 面臨著發展的困境和疲態。最明顯的感知就是這幾年以微信、支付寶、抖音等“超級 App”們大行其道,占據了用戶超... ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...