# 自定義路由
小程序采用多 WebView 架構(gòu),頁面間跳轉(zhuǎn)形式十分單一,僅能從右到左進行動畫。而原生 App 的動畫形式則多種多樣,如從底部彈起,頁面下沉,半屏等。
Skyline 渲染引擎下,頁面有兩種渲染模式: WebView 和 Skyline,它們通過頁面配置中的 renderer 字段進行區(qū)分。在連續(xù)的 Skyline 頁面間跳轉(zhuǎn)時,可實現(xiàn)自定義路由效果。
# 效果展示
下方為半屏頁面效果,點擊可查看更多 Skyline 示例。

掃碼打開小程序示例,交互動畫 - 基礎(chǔ)組件 - 自定義路由 即可體驗。
# 使用方法
建議先閱讀完 worklet 動畫 和 手勢系統(tǒng) 兩個章節(jié),它們是自定義路由的基礎(chǔ)內(nèi)容。
# 接口定義
自定義路由相關(guān)的接口
- 頁面跳轉(zhuǎn) wx.navigateTo
- 路由上下文對象 wx.router.getRouteContext
- 注冊自定義路由 wx.router.addRouteBuilder
type AddRouteBuilder = (routeType: string, routeBuilder: CustomRouteBuilder) => void
type CustomRouteBuilder = (routeContext: CustomRouteContext, routeOptions: Record<string, any>) => CustomRouteConfig
interface SharedValue<T> {
value: T;
}
interface CustomRouteContext {
// 動畫控制器,影響推入頁面的進入和退出過渡效果
primaryAnimation: SharedValue<number>
// 動畫控制器狀態(tài)
primaryAnimationStatus: SharedValue<number>
// 動畫控制器,影響棧頂頁面的推出過渡效果
secondaryAnimation: SharedValue<number>
// 動畫控制器狀態(tài)
secondaryAnimationStatus: SharedValue<number>
// 當前路由進度由手勢控制
userGestureInProgress: SharedValue<number>
// 手勢開始控制路由
startUserGesture: () => void
// 手勢不再控制路由
stopUserGesture: () => void
// 返回上一級,效果同 wx.navigateBack
didPop: () => void
}
interface CustomRouteConfig {
// 下一個頁面推入后,不顯示前一個頁面
opaque?: boolean;
// 是否保持前一個頁面狀態(tài)
maintainState?: boolean;
// 頁面推入動畫時長,單位 ms
transitionDuration?: number;
// 頁面推出動畫時長,單位 ms
reverseTransitionDuration?: number;
// 遮罩層背景色,支持 rgba() 和 #RRGGBBAA 寫法
barrierColor?: string;
// 點擊遮罩層返回上一頁
barrierDismissible?: boolean;
// 無障礙語義
barrierLabel?: string;
// 是否與下一個頁面聯(lián)動,決定當前頁 secondaryAnimation 是否生效
canTransitionTo?: boolean;
// 是否與前一個頁面聯(lián)動,決定前一個頁 secondaryAnimation 是否生效
canTransitionFrom?: boolean;
// 處理當前頁的進入/退出動畫,返回 StyleObject
handlePrimaryAnimation?: RouteAnimationHandler;
// 處理當前頁的壓入/壓出動畫,返回 StyleObject
handleSecondaryAnimation?: RouteAnimationHandler;
// 處理上一級頁面的壓入/壓出動畫,返回 StyleObject 基礎(chǔ)庫 <3.0.0> 起支持
handlePreviousPageAnimation?: RouteAnimationHandler;
// 頁面進入時是否采用 snapshot 模式優(yōu)化動畫性能 基礎(chǔ)庫 <3.2.0> 起支持
allowEnterRouteSnapshotting?: boolean
// 頁面退出時是否采用 snapshot 模式優(yōu)化動畫性能 基礎(chǔ)庫 <3.2.0> 起支持
allowExitRouteSnapshotting?: boolean
// 右滑返回時,可拖動范圍是否撐滿屏幕,基礎(chǔ)庫 <3.2.0> 起支持,常用于半屏彈窗
fullscreenDrag?: boolean
// 返回手勢方向 基礎(chǔ)庫 <3.4.0> 起支持
popGestureDirection?: 'horizontal' | 'vertical' | 'multi'
}
type RouteAnimationHandler = () => { [key: string] : any}
# 默認路由配置
const defaultCustomRouteConfig = {
opaque: true,
maintainState: true,
transitionDuration: 300,
reverseTransitionDuration: 300,
barrierColor: '',
barrierDismissible: false,
barrierLabel: '',
canTransitionTo: true,
canTransitionFrom: true,
allowEnterRouteSnapshotting: false,
allowExitRouteSnapshotting: false,
fullscreenDrag: false,
popGestureDirection: 'horizontal'
}
# 示例模板
以下是注冊自定義路由的一份示例模板(未添加手勢處理部分),完整實現(xiàn)半屏路由效果見示例代碼。
const customRouteBuiler = (routeContext: CustomRouteContext) : CustomRouteConfig => {
const {
primaryAnimation,
secondaryAnimation,
userGestureInProgress
} = routeContext
const handlePrimaryAnimation: RouteAnimationHandler = () => {
'worklet'
let t = primaryAnimation.value
if (!userGestureInProgress.value) {
// select another curve, t = xxx
}
// StyleObject
return {}
}
const handleSecondaryAnimation: RouteAnimationHandler = () => {
'worklet'
let t = secondaryAnimation.value
if (!userGestureInProgress.value) {
// select another curve, t = xxx
}
// StyleObject
return {}
}
return {
opaque: true,
handlePrimaryAnimation,
handleSecondaryAnimation
}
}
// 在頁面跳轉(zhuǎn)前定義好 routeBuilder
wx.router.addRouteBuilder('customRoute', customRouteBuiler)
// 跳轉(zhuǎn)新頁面時,指定對應的 routeType
wx.navigateTo({
url: 'xxxx',
routeType: 'customRoute'
})
# 工作原理
以半屏效果為例,路由前后頁面記為 A 頁、B 頁,一個路由的生命周期中,會經(jīng)歷如下階段:
push階段 :調(diào)用wx.navigateTo,B頁自底向上彈出,A頁下沉收縮- 手勢拖動:在
B頁上下滑動時,路由動畫隨之變化 pop階段 :調(diào)用wx.navigateBack,B頁向下關(guān)閉,A恢復原樣
細分到每個頁面,在上述階段會有以下動畫方式
- 進入/退出動畫
- 壓入/壓出動畫
- 手勢拖動
- 在
push階段,B頁進行的是進入動畫,A頁進行的是壓入動畫; - 在
pop階段,B頁進行的是退出動畫,A頁進行的是壓出動畫;
可以看到在路由過程中,前后兩個頁面動畫進行了聯(lián)動。在自定義路由模式下,我們可以對動畫各個階段的時長、曲線、效果以及是否聯(lián)動進行自定義,以實現(xiàn)靈活多變的頁面專場效果。
# 路由控制器
當打開新頁面時,框架會為其創(chuàng)建兩個 SharedValue 類型的動畫控制器 primaryAnimation 和 secondaryAnimation,分別控制進入/退出動畫和壓入/壓出動畫。
頁面的進入和退出可指定不同的時長,但進度變化始終在 0~1 之間。仍以半屏效果為例,路由前后頁面記為 A 頁、B 頁。
# push 階段
B頁對應的primaryAnimation從0 -> 1變化,做進入動畫A頁對應的secondaryAnimation從0 -> 1變化,做壓入動畫
# pop 階段
B頁對應的primaryAnimation從1 -> 0變化,做退出動畫A頁對應的secondaryAnimation從1 -> 0變化,做壓出動畫
其中,A 頁 secondaryAnimation 的值始終與 B 頁 primaryAnimation 的值同步變化。
通常頁面的進入和退出可能采用不同的動畫曲線,可通過對應的狀態(tài)變量 primaryAnimationStatus 和 secondaryAnimationStatus 來區(qū)分當前處于哪一階段,ts 定義如下
enum AnimationStatus {
// 動畫停在起點
dismissed = 0,
// 動畫從起點向終點進行
forward = 1,
// 動畫從終點向起點進行
reverse = 2,
// 動畫停在終點
completed = 3,
}
以 primaryAnimationStatus 為例,頁面進入和退出過程中變化情況如下
push階段:dismissed->forward->completedpop階段:completed->reverse->dismissed
# 路由手勢
在頁面推入后,除了調(diào)用 wx.navigateBack 接口返回上一級外,還可以通過手勢來處理,例如 iOS 上常見的右滑返回。自定義路由模式下,開發(fā)者可根據(jù)不同的頁面轉(zhuǎn)場效果,來選取所需的退出方式,如半屏效果可采用下滑返回。關(guān)于手勢監(jiān)聽的內(nèi)容,可參考 手勢系統(tǒng) 一章,路由手勢僅是在其基礎(chǔ)上,補充了幾個路由相關(guān)的接口。
startUserGesture 和 stopUserGesture 兩個函數(shù)總是成對調(diào)用的,startUserGesture 調(diào)用后 userGestureInProgress 的值會加 1。
當開發(fā)者自行修改 primaryAnimation 的值來控制路由進度的時候,就需要調(diào)用這兩個接口。由于手勢拖動過程中通常采用不同的動畫曲線,可通過 userGestureInProgress 值進行判斷。
當手勢處理后確定需要返回上一級頁面時,調(diào)用 didPop 接口,作用等同 wx.navigateBack。
# 路由聯(lián)動
路由動畫過程中,默認前后兩個頁面是一起聯(lián)動的,可通過配置項關(guān)閉。
canTransitionTo:是否與下一個頁面聯(lián)動,棧頂頁面該屬性置為false,推入下一頁面時,則棧頂頁面始終不動canTransitionFrom:是否與前一個頁面聯(lián)動,新推入頁面該屬性置為false,則棧頂頁面始終不動
# 路由上下文對象
由示例模版可見,自定義路由的動畫效果就是根據(jù) CustomRouteContext 上下文對象上的路由控制器,編寫適當?shù)膭赢嫺潞瘮?shù)來實現(xiàn)。
CustomRouteContext 上下文對象還可在頁面/自定義組件中通過 wx.router.getRouteContext(this) 讀取,進而在手勢處理過程中訪問,通過對 primaryAnimation 值的改寫實現(xiàn)頁面手勢返回。
小技巧:可在 CustomRouteContext 對象上添加一些私有屬性,在頁面中進行讀取/修改。
# 多類型路由跳轉(zhuǎn)
考慮這樣的場景,從頁面 A 可能跳轉(zhuǎn)到 B 頁和 C 頁,但具有不同的路由動畫
A->B時,希望實現(xiàn)半屏效果,A需要下沉收縮A->C時,希望采用普通路由,A需要向左移動
跳轉(zhuǎn)下一級頁面時的動畫由 handleSecondaryAnimation 控制,這樣就需要在定義 A 的 CustomRouteBuilder 時考慮所有的路由類型,實現(xiàn)較為繁瑣。
基礎(chǔ)庫 3.0.0 版本起,自定義路由新增 handlePreviousPageAnimation 接口,用于控制上一級頁面的壓入/壓出動畫。
const customRouteBuiler = (routeContext: CustomRouteContext) : CustomRouteConfig => {
const { primaryAnimation } = routeContext
const handlePrimaryAnimation: RouteAnimationHandler = () => {
'worklet'
let t = primaryAnimation.value
// 控制當前頁的進入和退出
}
const handlePreviousPageAnimation: RouteAnimationHandler = () => {
'worklet'
let t = primaryAnimation.value
// 控制上一級頁面的壓入和退出
}
return {
handlePrimaryAnimation,
handlePreviousPageAnimation
}
}
A 跳轉(zhuǎn)到 B 時, A 頁 secondaryAnimation 的值始終與 B 頁 primaryAnimation 的值同步變化。
我們可以在定義 B 的 CustomRouteBulder 時,通過 primaryAnimation 得知當前路由進度,handlePreviousPageAnimation 返回的 StyleObject 會作用于上一級頁面。
同時也不再需要提前聲明 A 為自定義路由,在此之前 A 跳轉(zhuǎn) B 希望實現(xiàn)半屏效果時,A 也必須定義為自定義路由。
完整的示例可參考如下代碼,借助 handlePreviousPageAnimation 可去掉對 secondaryAnimation 的依賴,簡化代碼邏輯。
# 實際案例
下面以半屏效果為例,講解自定義路由的具體實現(xiàn)過程,完整代碼見示例代碼。
路由前后頁面分別記為 A 頁和 B 頁,需要分別為其注冊自定義路由。未注冊任何自定義路由效果時,新打開的頁面 B 會立即覆蓋顯示在 A 頁上。
# Step-1 頁面進入動畫
我們先分別簡單實現(xiàn) 首頁 -> A 頁 -> B 頁的進入動畫,再一步步進行完善。
對于 A 頁面,進入方式為自右向左,通過 transform 平移實現(xiàn)。
function ScaleTransitionRouteBuilder(customRouteContext) {
const {
primaryAnimation
} = customRouteContext
const handlePrimaryAnimation = () => {
'worklet'
let t = primaryAnimation.value
const transX = windowWidth * (1 - t)
return {
transform: `translateX(${transX}px)`,
}
}
return {
handlePrimaryAnimation
}
}
對于 B 頁面,進入方式為自底向上,也是通過 transform 平移實現(xiàn),但需要對頁面大小、圓角進行修改。
const HalfScreenDialogRouteBuilder = (customRouteContext) => {
const {
primaryAnimation,
} = customRouteContext
const handlePrimaryAnimation = () => {
'worklet'
let t = primaryAnimation.value
// 距離頂部邊距因子
const topDistance = 0.12
// 距離頂部邊距
const marginTop = topDistance * screenHeight
// 半屏頁面大小
const pageHeight = (1 - topDistance) * screenHeight
// 自底向上顯示頁面
const transY = pageHeight * (1 - t)
return {
overflow: 'hidden',
borderRadius: '10px',
marginTop: `${marginTop}px`,
height: `${pageHeight}px`,
transform: `translateY(${transY}px)`,
}
}
return {
handlePrimaryAnimation,
}
}
頁面跳轉(zhuǎn)效果如下,可以看到由于采用線性曲線(未對 t 做任何變換),動畫有些呆板,同時未區(qū)分進入/退出動畫。在 B 頁完全進入后,A 頁變的不可見。
# Step-2 自定義動畫曲線
以 B 頁為例,根據(jù) AnimationStatus 值,采用不同的動畫曲線,同時設(shè)置 opaque 為 false,使得路由動畫完成后仍顯示 A 頁面。
const { Easing, derived } = wx.workelt
const Curves = {
linearToEaseOut: Easing.cubicBezier(0.35, 0.91, 0.33, 0.97),
easeInToLinear: Easing.cubicBezier(0.67, 0.03, 0.65, 0.09),
fastOutSlowIn: Easing.cubicBezier(0.4, 0.0, 0.2, 1.0),
fastLinearToSlowEaseIn: Easing.cubicBezier(0.18, 1.0, 0.04, 1.0),
}
function CurveAnimation({ animation, animationStatus, curve,reverseCurve }) {
return derived(() => {
'worklet'
const useForwardCurve = !reverseCurve || animationStatus.value !== AnimationStatus.reverse
const activeCurve = useForwardCurve ? curve : reverseCurve
const t = animation.value
if (!activeCurve) return t
if (t === 0 || t === 1) return t
return activeCurve(t)
})
}
const HalfScreenDialogRouteBuilder = (customRouteContext) => {
const {
primaryAnimation,
primaryAnimationStatus,
} = customRouteContext
// 1. 頁面進入時,采用 Curves.linearToEaseOut 曲線
// 2. 頁面退出時,采用 Curves.easeInToLinear 曲線
const _curvePrimaryAnimation = CurveAnimation({
animation: primaryAnimation,
animationStatus: primaryAnimationStatus,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
})
const handlePrimaryAnimation = () => {
'worklet'
let t = _curvePrimaryAnimation.value
... // 其余內(nèi)容等上面的代碼一致
}
return {
opaque: false,
handlePrimaryAnimation,
}
}
這里的區(qū)別僅在于,當前的進度不再直接讀取 primaryAnimation 的值。封裝的 CurveAnimation 函數(shù)會根據(jù) AnimationStatus 判斷是處于進入還是退出狀態(tài),從而選擇不同的動畫曲線。框架提供了多種曲線類型,可進一步參考 worklet.Easing。改進后的頁面轉(zhuǎn)場效果如下
# Step-3 頁面聯(lián)動效果
B 頁進入時,A 頁作壓入動畫,由 secondaryAnimation 控制。接下來,我們?yōu)槠涮砑酉鲁列Ч?,實現(xiàn)和 B 頁的聯(lián)動。
function ScaleTransitionRouteBuilder(customRouteContext) {
const {
primaryAnimation
} = customRouteContext
const handlePrimaryAnimation = () => {
'worklet'
...
}
const _curveSecondaryAnimation = CurveAnimation({
animation: secondaryAnimation,
animationStatus: secondaryAnimationStatus,
curve: Curves.fastOutSlowIn,
})
const handleSecondaryAnimation = () => {
'worklet'
let t = _curveSecondaryAnimation.value
// 頁面縮放大小
const scale = 0.08
// 距離頂部邊距因子
const topDistance = 0.1
// 估算的偏移量
const transY = screenHeight * (topDistance - 0.5 * scale) * t
return {
overflow: 'hidden',
borderRadius: `${ 12 * t }px`,
transform: `translateY(${transY}px) scale(${ 1 - scale * t })`,
}
}
return {
handlePrimaryAnimation,
handleSecondaryAnimation
}
}
通過對 A 頁作 scale 和 translate 變換實現(xiàn)下沉效果。A 頁 secondaryAnimation 的值始終與 B 頁 primaryAnimation 的值保持同步。
頁面是否聯(lián)動還可通過 canTransitionTo 和 canTransitionFrom 兩個屬性進行配置,可在開發(fā)者工具上修改體驗。
# Step-4 手勢返回
目前動畫效果已經(jīng)基本實現(xiàn),還需要最后一步,手勢返回。對于半屏效果,我們?yōu)?A 頁添加右滑返回手勢,B 頁添加下滑返回手勢。
以最常見的右滑返回為例,這里只截取松手后的手勢處理部分代碼,拖動過程實現(xiàn)較為簡單,可參考示例代碼。
page({
handleDragEnd(velocity) {
'worklet';
const {
primaryAnimation,
stopUserGesture,
didPop
} = this.customRouteContext;
let animateForward = false;
if (Math.abs(velocity) >= 1.0) {
animateForward = velocity <= 0;
} else {
animateForward = primaryAnimation.value > 0.5;
}
const t = primaryAnimation.value;
const animationCurve = Curves.fastLinearToSlowEaseIn;
if (animateForward) {
const duration = Math.min(
Math.floor(lerp(300, 0, t)),
300,
);
primaryAnimation.value = timing(
1.0, {
duration,
easing: animationCurve,
},
() => {
'worklet'
stopUserGesture();
},
);
} else {
const duration = Math.floor(lerp(0, 300, t));
primaryAnimation.value = timing(
0.0, {
duration,
easing: animationCurve,
},
() => {
'worklet'
stopUserGesture();
didPop();
},
);
}
},
})
首先根據(jù)松手時的速度和位置,決定是否要真正返回上一級。
- 向右滑動且速度大于
1 - 或者速度較小時,已拖動超過屏幕
1/2
滿足以上條件時,確定返回。通過 timing 接口,為 primaryAnimation 添加過渡動畫,使其變化到 0,最后調(diào)用 didPop 。否則使其變化到 1,恢復到拖動前的狀態(tài)。
這里需要注意的是,當需要對 primaryAnimation 值手動修改,自由掌控其過渡方式時,才需要調(diào)用 startUserGesture 和 stopUserGesture 接口。
右滑手勢已經(jīng)在示例代碼中封裝成 swipe-back 組件,開發(fā)者可直接使用。下滑手勢返回邏輯基本一致,僅一些數(shù)值上略有差異。
最后的實現(xiàn)效果如圖
# 設(shè)置頁面透明
一些自定義路由效果下,需要實現(xiàn)頁面透明背景,這里對 Skyline 和 webview 模式下背景色的層級關(guān)系進行說明。
# 自定義路由下的頁面背景色
Skyline 模式下使用自定義路由方式跳轉(zhuǎn)頁面,頁面背景色有如下幾層
- 頁面背景色:可通過
page選擇器在wxss中定義,默認為白色 - 頁面容器背景色:可在頁面
json文件中通過backgroundColorContent屬性定義,支持#RRGGBBAA寫法,默認白色 - 自定義路由容器背景色,由路由配置項中返回的
StyleObject控制,默認透明 - 控制是否顯示前一個頁面,由路由配置項中的
opaque字段控制,默認不顯示
當需要設(shè)置下一個頁面漸顯進入時,可簡單設(shè)置
- 頁面背景色透明:
page { background-color: transparent; } - 頁面容器背景色透明:
backgroundColorContent: "#ffffff00"
# webview 下的頁面背景色
對比看下,webview 模式下的頁面背景色
- 頁面背景色:可通過
page選擇器在wxss中定義,默認為透明 - 頁面容器背景色:可在頁面
json文件中通過backgroundColorContent屬性定義,支持#RRGGBB寫法,默認白色 - 窗口背景色:可通過 wx.setBackgroundColor 接口或頁面配置修改,默認為白色