- Skyline|小程序吸顶、网格、瀑布流布局都拿下~
在之前的文章中,我们知道了新 scroll-view 可以让小程序的长列表做到丝滑滚动~ 也提到了新 scroll-view 提供了很多新能力 sticky、网格布局、瀑布流布局等,这一篇,我们就来看看这些新能力是怎么使用的~ 新 scroll-view 在原来列表模式(type="list")的基础上,新增了自定义模式(type="custom") 在自定义模式下,新增了以下新组件供开发者调用 list-view:列表布局容器sticky-section / sticky-header:吸顶布局容器grid-view:网格布局容器,可实现网格布局、瀑布流布局等sticky布局sticky 布局即在应用中常见的吸顶布局,与 CSS 中的 position: sticky 实现的效果一致,当组件在屏幕范围内时,会按照正常的布局排列,当组件滚出屏幕范围时,始终会固定在屏幕顶部。 常见的使用场景有:通讯录、账单列表、菜单列表等等。 与 position: sticky 不同的是,position: sticky 很难实现列表滚动需要的交错吸顶效果,而 sticky 组件则可以帮忙开发者轻松实现交错吸顶的效果。 sticky 的使用非常简单: 将 scroll-view 切换到 custom 模式采用 sticky-section 作为 scroll-view 的子元素sticky-header 放置吸顶内容list-view 放置列表内容 {{item.name}} ... 我们来看下采用 sticky 布局做出来的通讯录效果~ [视频] sticky 布局也可以通过给 sticky-section 配置 push-pinned-header 来声明吸顶元素重叠时是否继续上推 像下图输入框和标签列表这种类型,标签列表吸顶时还是希望保留输入框吸顶。 [视频] 网格布局网格布局即将列表切割成格子,每一行的高度固定,常见的视频列表、照片列表等通常都采用网格布局。 在此之前,实现网格布局需要开发者自行实现网格切割,再嵌入到 scroll-view 中。 新 scroll-view 直接提供了 grid-view 组件供开发者使用~ 将 scroll-view 切换到 custom 模式采用 grid-view 类型为 aligned 做为直接子节点grid-view 中直接编写列表 ... 下面是使用网格布局实现的图片列表效果~ [视频] 瀑布流布局瀑布流布局与网格布局类似,不同的是瀑布流布局中每个格子的高度都可以是不一致的,所以在小程序中实现瀑布流布局就比较复杂了。 开发者需要通过计算格子高度,然后再进行瀑布流拼接,当滚动内容过多时还需要处理节点过多导致内存不足等问题。 grid-view 组件直接支持了瀑布流模式供开发者直接使用,grid-view 组件会根据子元素高度自动布局: 将 scroll-view 切换到 custom 模式采用 grid-view 类型为 masonry 做为直接子节点grid-view 中直接编写列表 ... 下面是使用瀑布流布局实现的图片列表效果~ [视频] 想要立即体验?现在通过微信开发者工具导入 代码片段,即可体验新版 scroll-view 组件能力~
2023-08-03 - Skyline|原生级卡片转场,小程序轻松实现
在上一篇文章《在小程序中实现原生相册》中,我们学习了自定义路由搭配共享元素实现的原生相册效果,共享元素可以让用户在体验小程序时视觉关联性更强。 除了相册实现之外,常见的卡片转场也非常适合。 [图片] ⬆️ 演示效果:默认动画 vs 卡片转场动画 👇 下面我们来看看卡片转场中通过 共享元素 + 自定义路由 来实现无痕跳转。 [图片] 这里的转场稍微有点复杂,涉及到以下 3 个点 旧卡片:图片放大、内容渐隐新页面:按比例放大、页面渐显手势搭配1、旧卡片:图片放大、内容渐隐 在本示例中,列表页采用的是 scroll-view 瀑布流布局的实现。 [图片] 这里我们的共享元素是卡片,即 grid-view 中的内容 card,卡片包括 图片、内容描述。 [图片] 默认情况下,共享元素是整个节点进行飞跃的,由于前后页面的图片元素一致但文本内容不一致, 导致在第一帧或者最后一帧会有跳动的效果。 为了让转场动画更加自然,我们需要在飞跃的过程中渐隐旧卡片的内容描述。 [图片] 在这里,我们需要先用 this.applyAnimatedStyle 来给对应的节点绑定 worklet 驱动动画。 .card_wrap 节点:整个卡片按比例放大.card_desc 节点:内容描述渐隐[图片] 关于动画执行的时机,我们可以通过配置项修改。 immediate:设置是否立即执行驱动动画flush:shareValue 更新时,applyAnimatedStyle 的 updater 函数刷新时机在本例中,需要保证共享元素的图片与目标页面图片位置重叠,所以 flush 设置 sync 在当前时间片刷新。 [图片] 绑定完驱动动画之后,我们需要给共享元素绑定帧回调事件,根据当前动画进度改变共享变量的值来驱动共享动画 [图片] 2、新页面:按比例放大、页面渐显 新页面在路由中的动画,需要在自定义路由中进行配置。关于自定义路由的更多介绍,可参考《小程序页面转场动画》 在路由动画过程中,我们将上一步的共享元素帧回调拿到 begin、end 的值,然后结合动画进度 t 计算得出新页面的位置、缩放比例。 还有根据动画进度,设置页面渐显,与前面的卡片渐隐承接。 [图片] 3、手势搭配 学习过我们前面的文章的同学都知道,自定义路由经常需要结合页面手势,来实现手势返回,关于手势的基础知识可参考《小程序页面转场动画》 [图片] 这里我们希望手势缩小整个当前页面,所以这里手势返回时只在当前页面做手势动画即可。 在页面详情页的最外层,嵌套一个手势组件 pan-gesture-handler,当手势拖动时根据手势的位置改变整个页面(通过 #fake-host 控制)的位置和大小来达到拖动的效果。 [图片] 同样绑定页面驱动动画,通过 applyAnimatedStyle 给 #fake-host 绑定驱动动画,当共享变量 transX、transY 等变化时则自动改变 transform 来驱动 #fake-host 缩小。 [图片] 接着绑定手势事件,根据手势拖动时拿到位置信息改变共享变量 transX、transY 的值。 [图片] 最后我们需要设置背景颜色透明,来达到类似把卡片拖回列表的视觉效果,更好的减少页面切换感~ [图片] 一个自定义路由的页面会有 3 层可以设置到背景色,要做到透明的效果需要将 3 个背景色都设置为透明。更多自定义路由背景色的详情参考官方文档。 [图片] 想要试试卡片转场的无恒效果~扫描 ⬇️ 下方小程序码即可体验。 如果你也想在小程序中实现卡片转场动画,mark 下这个 源码 直接接到到你的小程序吧~ [图片]
2023-08-03 - 岁寒之松柏:小程序skyline渲染引擎初尝试
小程序架构介绍 我们都知道小程序本质上是运行在安卓端,苹果端的混合APP,只是微信提供了一套JSBridge,方便用户对一些原生功能和微信相关的功能的进行调用。而微信为了安全和性能的需要,一改以往网络架构中的单线程架构,改为小程序的双线程架构。分别是AppServie 和 Webview 两个线程,我们在小程序中编写的JS代码就是运行在AppService线程的JSCore引擎(类似V8 引擎,一个Js解释器)中,而我们的Wxml和Wxss则会依赖WebView线程进行渲染。 [图片] 目前架构存在的问题 这样的架构虽然已经极大了提高了webview的渲染性能,但是依然会存在一些问题比如: 当页面节点数目过多,很容易发生卡顿 当我们新建一个页面,就要新建一个Webview进行渲染 页面之间共享资源,需要使用Native进行通信,就会消耗更多性能 当AppService(逻辑层)与Webview(视图层)通信也需要依赖Native 所以为了解决这些问题小程序推出Skyline渲染引擎 Skyline引擎介绍 在Skyline环境中,Skyline 会创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。说白了就是之前的样式计算是放到渲染线程来处理,现在把和样式相关的逻辑也放到AppService线程中处理,个人猜测这个渲染线程很有可能很有可能就是flutter,这样的架构就极大减少内存的消耗,和线程上通信时间的消耗。原本wxs中的逻辑,也可以移到Appservice线程中运行 [图片] 使用Skyline引擎的使用步骤 在app.json 文件添加 [代码]"lazyCodeLoading": "requiredComponents"[代码] 属性,这是因为Skyline 依赖按需注入的特性。 [代码] { "pages": [ "pages/index/index", "pages/logs/logs", "pages/test/test" ], "window": { "backgroundTextStyle": "light", "navigationBarBackgroundColor": "#fff", "navigationBarTitleText": "Weixin", "navigationBarTextStyle": "black" }, "sitemapLocation": "sitemap.json", // 在 app.json 文件添加 "lazyCodeLoading": "requiredComponents" } [代码] 在全局或页面配置中声明为 Skyline 渲染,即 app.json 或 page.json 配上[代码]"renderer": "skyline"[代码] Skyline 不支持页面全局滚动,需在页面配置项加上 [代码]"disableScroll": true[代码],在需要滚动的区域使用scroll-view 实现 Skyline 不支持原生导航栏,需在页面配置项加上 [代码]"navigationStyle": "custom"[代码],并自行实现自定义导航栏 [代码] { "usingComponents": {}, // 在 app.json 文件添加或者page页面的json中添加 "disableScroll": true, "navigationStyle": "custom" //也可以放在App.json文件中 "renderer": "skyline" } [代码] 组件适配,参考 Skyline 基础组件支持与差异 WXSS 适配,参考 Skyline WXSS 样式支持与差异 在本地设置中勾选Skyline渲染调试,如果看不到这个选项框,看一下是否在app.json中配置了[代码]"renderer": "skyline"[代码] [图片] Skyline的 worklet 动画介绍 小程序采用双线程架构,渲染线程(UI 线程)和逻辑线程(JS 线程)分离。[代码]JS[代码] 线程不会影响 [代码]UI[代码] 线程的动画表现,如滚动效果。但引入的问题是,[代码]UI[代码] 线程的事件发生后,需跨线程传递到 [代码]JS[代码] 线程,进而触发开发者回调,当做交互动画(如拖动元素)时,这种异步性会带来较大的延迟和不稳定,[代码]worklet[代码] 动画正是为解决这类问题而诞生的,使得小程序可以做到类原生动画般的体验 worklet函数定义 [代码]function helloWorklet() { 'worklet'; //'worklet'声明该函数为work函数,可以在js线程和UI线程中调用 console.log('hello worklet'); } Page({ onLoad(options) { helloWorklet('hello') // print: hello wx.worklet.runOnUI(helloWorklet)() }, }) [代码] 在小程序控制台可以看到如下输出 [图片] 如果看见SkylineGlobal is not defined错误看看是否开启了Skyline渲染调试 [图片] worklet函数间的相互调用 [代码]function slave() { 'worklet'; return "I am slave" } function master() { 'worklet'; const value = slave() console.log(value); } [代码] 从 UI 线程调回到 JS 线程 [代码]const {runOnUI ,runOnJS} = wx.worklet function jsFun(message) { // 普通函数不需要声明为worklet console.log(message) } function uiFun() { 'worklet'; runOnJS(jsFun)('I am from UI') } [代码] 使用shared共享数据 由worklet函数捕获的静态变量,会在编译期间序列化后生成在UI线程的拷贝环境之中,这就导致我们在JS线程中后续更新了变量,但是在UI线程中时得不到最新的数值的。 [代码]const obj = { name: 'skyline'} function someWorklet() { 'worklet' console.log(obj.name) // 输出的仍旧是 skyline } obj.name = 'change name' wx.worklet.runOnUI(someWorklet)() [代码] 因此shyline使用shared来实现线程之间数据的共享 [代码]const { shared, runOnUI } = wx.worklet const offset = shared(0) function someWorklet() { 'worklet' console.log(offset.value) // 输出的是新值 1 } offset.value = 1 runOnUI(someWorklet)() [代码] 简单案例–实现探探的卡片功能 注意:编辑器版本:1.06.2303162 基础库版本:2.30.2 先看效果 [图片] 代码如下 <br> wxml 代码 [代码]<navigation-bar title="探探" /> <view class="page"> <block wx:for="{{containers}}" wx:key="*this"> <pan-gesture-handler data-id="container-{{index}}" onGestureEvent="handlePan"> <view id="container-{{index}}" class="container" style="z-index: {{zIdnexes[index]}};background-image: url({{partContentList[index]}});"> </view> </pan-gesture-handler> </block> </view> [代码] scss代码 [代码].page{ display: flex; justify-content: center; align-items: center; height: 100vh; width: 100vw; position: relative; .container{ height: 80vh; width: 95vw; background-color: burlywood; position: absolute; border-radius: 16rpx; display: flex; justify-content: center; align-items: center; background-size: cover; .image{ display: block; height: 1067rpx; width: 712rpx; margin: 0 0; } } } [代码] 核心逻辑 [代码]import { useAnimation, setAni, Animation, GestureState } from "./method" Page<{ pos: Animation }, any>({ /** * 页面的初始数据 */ data: { containers: [ "burlywood", "blue", "cyan", "black" ], zIdnexes:[], current:0, partContentList:[] }, /** * 生命周期函数--监听页面加载 */ onLoad() { this.initNode() // 当前node的下标 this.active = wx.worklet.shared(0) // 当前contentList的下标 this.current = wx.worklet.shared(0) this.zIndex = 100000 }, initNode() { // 用与保存shared值 this.Nodes = {} // 图片文件 this.contentList = [ "https://i.hexuexiao.cn/up/ca/63/4a/a32912fc26b8445797c8095ab74a63ca.jpg", "https://th.bing.com/th/id/OIP.kSrrRGx6nqOgWzbaEvVD9AHaNK?pid=ImgDet&rs=1", "https://img.zmtc.com/2019/0806/20190806061552744.jpg", "https://img.zmtc.com/2019/0806/20190806061000600.jpg", "https://img.ratoo.net/uploads/allimg/190523/7-1Z5231J058.jpg", "https://th.bing.com/th/id/R.47de9dfcc25d579d84850d4575d24a6a?rik=%2fGkmrewzIEY4Iw&riu=http%3a%2f%2fimg3.redocn.com%2ftupian%2f20150930%2fqizhimeinvlisheyingtu_5034226.jpg&ehk=rG9Ks2QRzj81mZl38gVGmWVAgCHVLWppoDezpfwdxjo%3d&risl=&pid=ImgRaw&r=0", "https://th.bing.com/th/id/R.95f8e6f6bd5b660ae3ad4f3e0d712276?rik=ELKcha%2bE5ryuiw&riu=http%3a%2f%2f222.186.12.239%3a10010%2fwlp_180123%2f003.jpg&ehk=mVN7AzIRR%2fmVPJYWrWOFbEiher3QWtwSdH%2f%2fe4lE7n8%3d&risl=&pid=ImgRaw&r=0" ] this.data.containers.forEach((_: string, index: number) => { if (index == 0) { this.Nodes[`#container-${index}`] = useAnimation(`#container-${index}`, { x: 0, y: 0 }, this) this.setData({ [`zIdnexes[${index}]`]:100000-index, [`partContentList[${index}]`]:this.contentList[index] }) } else { console.log("10123") this.Nodes[`#container-${index}`] = useAnimation(`#container-${index}`, { x: 0, y: 20, scale: 0.95 }, this) this.setData({ [`zIdnexes[${index}]`]:100000-index, [`partContentList[${index}]`]:this.contentList[index] }) } }); }, handlePan(evt: any) { "worklet"; console.log(evt) const now = this.Nodes[`#container-${this.active.value}`] as Animation const next = this.Nodes[`#container-${(this.active.value+1)%4}`] as Animation if (evt.state == GestureState.ACTIVE) { // 滑动激活状态 // 设置当前的滑动块 now.x.value += evt.deltaX now.y.value += evt.deltaY now.rotate.value = now.x.value * 10 / 360 // 设置下一个滑动块 let rate = Math.abs(now.x.value) / 150 rate = rate > 1 ? 1 : rate next.y.value = (20 - rate * 20) < 0 ? 0 : (20 - rate * 20) next.scale.value = 0.95 + rate * 0.05 } if (evt.state == GestureState.END) { // 滑动结束 if (Math.abs(now.x.value) < 150) { // 判断是否超过界限值 setAni(now.x, 0) setAni(now.y, 0) setAni(now.rotate, 0) } else if (now.x.value < 0) { // 判断判断左划还是右划 setAni(now.x, -2000) setAni(now.y, -2000) setAni(now.rotate, 0) // 通知js线程进行数据的更新 wx.worklet.runOnJS(this.toNext.bind(this))() } else if (now.x.value > 0) { setAni(now.x, 2000) setAni(now.y, -2000) setAni(now.rotate, 0) wx.worklet.runOnJS(this.toNext.bind(this))() } } }, // 将当前序号的跳转到下一个 toNext(){ const current = this.current.value+1 this.active.value = current%4 this.current.value = current this.setData({ current }) if(current-2>=0){ wx.worklet.runOnUI(this.toReset)((current-2)%4) this.setData({ [`zIdnexes[${(current-2)%4}]`]:99998-current, [`partContentList[${(current-2)%4}]`]:this.contentList[current+2] }) } }, // 将动画归位 toReset(index:number){ "worklet"; const reset = this.Nodes[`#container-${index}`] as Animation setAni(reset.x, 0,0) setAni(reset.y, 20,0) setAni(reset.rotate, 0,0) setAni(reset.scale, 0.95,0) } }) [代码] 参考 skyline worklet 动画
2023-03-20 - 小程序同层渲染原理剖析
众所周知,小程序当中有一类特殊的内置组件——原生组件,这类组件有别于 WebView 渲染的内置组件,他们是交由原生客户端渲染的。原生组件作为 Webview 的补充,为小程序带来了更丰富的特性和更高的性能,但同时由于脱离 Webview 渲染也给开发者带来了不小的困扰。在小程序引入「同层渲染」之前,原生组件的层级总是最高,不受 [代码]z-index[代码] 属性的控制,无法与 [代码]view[代码]、[代码]image[代码] 等内置组件相互覆盖, [代码]cover-view[代码] 和 [代码]cover-image[代码] 组件的出现一定程度上缓解了覆盖的问题,同时为了让原生组件能被嵌套在 [代码]swiper[代码]、[代码]scroll-view[代码] 等容器内,小程序在过去也推出了一些临时的解决方案。但随着小程序生态的发展,开发者对原生组件的使用场景不断扩大,原生组件的这些问题也日趋显现,为了彻底解决原生组件带来的种种限制,我们对小程序原生组件进行了一次重构,引入了「同层渲染」。 相信已经有不少开发者已经在日常的小程序开发中使用了「同层渲染」的原生组件,那么究竟什么是「同层渲染」?它背后的实现原理是怎样的?它是解决原生组件限制的银弹吗?本文将会为你一一解答这些问题。 什么是「同层渲染」? 首先我们先来了解一下小程序原生组件的渲染原理。我们知道,小程序的内容大多是渲染在 WebView 上的,如果把 WebView 看成单独的一层,那么由系统自带的这些原生组件则位于另一个更高的层级。两个层级是完全独立的,因此无法简单地通过使用 [代码]z-index[代码] 控制原生组件和非原生组件之间的相对层级。正如下图所示,非原生组件位于 WebView 层,而原生组件及 [代码]cover-view[代码] 与 [代码]cover-image[代码] 则位于另一个较高的层级: [图片] 那么「同层渲染」顾名思义则是指通过一定的技术手段把原生组件直接渲染到 WebView 层级上,此时「原生组件层」已经不存在,原生组件此时已被直接挂载到 WebView 节点上。你几乎可以像使用非原生组件一样去使用「同层渲染」的原生组件,比如使用 [代码]view[代码]、[代码]image[代码] 覆盖原生组件、使用 [代码]z-index[代码] 指定原生组件的层级、把原生组件放置在 [代码]scroll-view[代码]、[代码]swiper[代码]、[代码]movable-view[代码] 等容器内,通过 [代码]WXSS[代码] 设置原生组件的样式等等。启用「同层渲染」之后的界面层级如下图所示: [图片] 「同层渲染」原理 你一定也想知道「同层渲染」背后究竟采用了什么技术。只有真正理解了「同层渲染」背后的机制,才能更高效地使用好这项能力。实际上,小程序的同层渲染在 iOS 和 Android 平台下的实现不同,因此下面分成两部分来分别介绍两个平台的实现方案。 iOS 端 小程序在 iOS 端使用 WKWebView 进行渲染的,WKWebView 在内部采用的是分层的方式进行渲染,它会将 WebKit 内核生成的 Compositing Layer(合成层)渲染成 iOS 上的一个 WKCompositingView,这是一个客户端原生的 View,不过可惜的是,内核一般会将多个 DOM 节点渲染到一个 Compositing Layer 上,因此合成层与 DOM 节点之间不存在一对一的映射关系。不过我们发现,当把一个 DOM 节点的 CSS 属性设置为 [代码]overflow: scroll[代码] (低版本需同时设置 [代码]-webkit-overflow-scrolling: touch[代码])之后,WKWebView 会为其生成一个 [代码]WKChildScrollView[代码],与 DOM 节点存在映射关系,这是一个原生的 [代码]UIScrollView[代码] 的子类,也就是说 WebView 里的滚动实际上是由真正的原生滚动组件来承载的。WKWebView 这么做是为了可以让 iOS 上的 WebView 滚动有更流畅的体验。虽说 [代码]WKChildScrollView[代码] 也是原生组件,但 WebKit 内核已经处理了它与其他 DOM 节点之间的层级关系,因此你可以直接使用 WXSS 控制层级而不必担心遮挡的问题。 小程序 iOS 端的「同层渲染」也正是基于 [代码]WKChildScrollView[代码] 实现的,原生组件在 attached 之后会直接挂载到预先创建好的 [代码]WKChildScrollView[代码] 容器下,大致的流程如下: 创建一个 DOM 节点并设置其 CSS 属性为 [代码]overflow: scroll[代码] 且 [代码]-webkit-overflow-scrolling: touch[代码]; 通知客户端查找到该 DOM 节点对应的原生 [代码]WKChildScrollView[代码] 组件; 将原生组件挂载到该 [代码]WKChildScrollView[代码] 节点上作为其子 View。 [图片] 通过上述流程,小程序的原生组件就被插入到 [代码]WKChildScrollView[代码] 了,也即是在 [代码]步骤1[代码] 创建的那个 DOM 节点对应的原生 ScrollView 的子节点。此时,修改这个 DOM 节点的样式属性同样也会应用到原生组件上。因此,「同层渲染」的原生组件与普通的内置组件表现并无二致。 Android 端 小程序在 Android 端采用 chromium 作为 WebView 渲染层,与 iOS 不同的是,Android 端的 WebView 是单独进行渲染而不会在客户端生成类似 iOS 那样的 Compositing View (合成层),经渲染后的 WebView 是一个完整的视图,因此需要采用其他的方案来实现「同层渲染」。经过我们的调研发现,chromium 支持 WebPlugin 机制,WebPlugin 是浏览器内核的一个插件机制,主要用来解析和描述embed 标签。Android 端的同层渲染就是基于 [代码]embed[代码] 标签结合 chromium 内核扩展来实现的。 [图片] Android 端「同层渲染」的大致流程如下: WebView 侧创建一个 [代码]embed[代码] DOM 节点并指定组件类型; chromium 内核会创建一个 [代码]WebPlugin[代码] 实例,并生成一个 [代码]RenderLayer[代码]; Android 客户端初始化一个对应的原生组件; Android 客户端将原生组件的画面绘制到步骤2创建的 [代码]RenderLayer[代码] 所绑定的 [代码]SurfaceTexture[代码] 上; 通知 chromium 内核渲染该 [代码]RenderLayer[代码]; chromium 渲染该 [代码]embed[代码] 节点并上屏。 [图片] 这样就实现了把一个原生组件渲染到 WebView 上,这个流程相当于给 WebView 添加了一个外置的插件,如果你有留意 Chrome 浏览器上的 pdf 预览,会发现实际上它也是基于 [代码]<embed />[代码] 标签实现的。 这种方式可以用于 map、video、canvas、camera 等原生组件的渲染,对于 input 和 textarea,采用的方案是直接对 chromium 的组件进行扩展,来支持一些 WebView 本身不具备的能力。 对比 iOS 端的实现,Android 端的「同层渲染」真正将原生组件视图加到了 WebView 的渲染流程中且 embed 节点是真正的 DOM 节点,理论上可以将任意 WXSS 属性作用在该节点上。Android 端相对来说是更加彻底的「同层渲染」,但相应的重构成本也会更高一些。 「同层渲染」 Tips 通过上文我们已经了解了「同层渲染」在 iOS 和 Android 端的实现原理。Android 端的「同层渲染」是基于 chromium 内核开发的扩展,可以看成是 webview 的一项能力,而 iOS 端则需要在使用过程中稍加注意。以下列出了若干注意事项,可以帮助你避免踩坑: Tips 1. 不是所有情况均会启用「同层渲染」 需要注意的是,原生组件的「同层渲染」能力可能会在特定情况下失效,一方面你需要在开发时稍加注意,另一方面同层渲染失败会触发 [代码]bindrendererror[代码] 事件,可在必要时根据该回调做好 UI 的 fallback。根据我们的统计,目前同层失败率很低,也不需要太过于担心。 对 Android 端来说,如果用户的设备没有微信自研的 [代码]chromium[代码] 内核,则会无法切换至「同层渲染」,此时会在组件初始化阶段触发 [代码]bindrendererror[代码]。而 iOS 端的情况会稍复杂一些:如果在基础库创建同层节点时,节点发生了 WXSS 变化从而引起 WebKit 内核重排,此时可能会出现同层失败的现象。解决方法:应尽量避免在原生组件上频繁修改节点的 WXSS 属性,尤其要尽量避免修改节点的 [代码]position[代码] 属性。如需对原生组件进行变换,强烈推荐使用 [代码]transform[代码] 而非修改节点的 [代码]position[代码] 属性。 Tips 2. iOS 「同层渲染」与 WebView 渲染稍有区别 上文我们已经了解了 iOS 端同层渲染的原理,实际上,WebKit 内核并不感知原生组件的存在,因此并非所有的 WXSS 属性都可以在原生组件上生效。一般来说,定位 (position / margin / padding) 、尺寸 (width / height) 、transform (scale / rotate / translate) 以及层级 (z-index) 相关的属性均可生效,在原生组件外部的属性 (如 shadow、border) 一般也会生效。但如需对组件做裁剪则可能会失败,例如:[代码]border-radius[代码] 属性应用在父节点不会产生圆角效果。 Tips 3. 「同层渲染」的事件机制 启用了「同层渲染」之后的原生组件相比于之前的区别是原生组件上的事件也会冒泡,意味着,一个原生组件或原生组件的子节点上的事件也会冒泡到其父节点上并触发父节点的事件监听,通常可以使用 [代码]catch[代码] 来阻止原生组件的事件冒泡。 Tips 4. 只有子节点才会进入全屏 有别于非同层渲染的原生组件,像 [代码]video[代码] 和 [代码]live-player[代码] 这类组件进入全屏时,只有其子节点会被显示。 [图片] 总结 阅读本文之后,相信你已经对小程序原生组件的「同层渲染」有了更深入的理解。同层渲染不仅解决了原生组件的层级问题,同时也让原生组件有了更丰富的展示和交互的能力。下表列出的原生组件都已经支持了「同层渲染」,其他组件( textarea、camera、webgl 及 input)也会在近期逐步上线。现在你就可以试试用「同层渲染」来优化你的小程序了。 支持同层渲染的原生组件 最低版本 video v2.4.0 map v2.7.0 canvas 2d(新接口) v2.9.0 live-player v2.9.1 live-pusher v2.9.1
2019-11-21 - Skyline|在小程序实现原生相册的效果
相册在日常生活中经常使用到,如手机自带相册、朋友圈、商品展示图、评论贴图等等,都经常用到相册的能力。 👇下面演示 iOS 原生相册、朋友圈等相册使用效果,我们可以看到图片切换非常顺滑,视觉焦点不变。 [图片] 😭 但是在小程序中,页面切换会有明显的切换感。用户焦点会丢失,缺少视觉关联性。 [图片] 共享元素🔥 为了丰富用户交互效果、提升用户体验、增强视觉关联性,小程序支持了页面间的共享元素 下图展示有无共享元素的页面切换效果,可以看出使用共享元素之后,转场动画更灵活 [图片] 共享元素 经常作用在图片上,例如上面示例中的相册效果,是那么共享元素动画要怎么实现呢? 在页面跳转时,两个页面 key 相同的 share-element 组件则会产生飞跃的过渡效果 [图片] 在上一篇文章中,我们学习了 页面转场动画,共享元素动画跟页面转场动画是类似的,同样是在页面切换间的动画。 动画进度、时间 与 路由进度、时间保持一致(非自定义路由也支持共享元素动画) 在共享元素飞跃的过程中,前后页面图片的裁剪方式(mode) 可能不一致 这种情况下容易导致图片突然跳变,所以我们需要在飞跃的过程中改变图片的大小来保证平滑飞跃 [图片] 在共享元素动画进行的过程中,share-element 可以收到 onFrame 表示动画帧回调 我们可以在帧回调中处理内部元素的显示 例如:我们这里通过在帧回调中改变图片宽高来达到平滑飞跃的效果 // .wxml // .js // 初始化 attached() { this.aspectRatio = shared(0) this.curRect = shared(undefined) // 绑定 worklet 动画 this.applyAnimatedStyle('.img', () => { 'worklet' const curRect = this.curRect.value return { left: `${curRect.left}px`, top: `${curRect.top}px`, width: `${curRect.width}px`, height: `${curRect.height}px` } }) }, // 获取图片初始宽高比 onImageLoad(e) { const { width, height } = e.detail this.aspectRatio.value = width / height }, // 动画帧回调,调整图片大小 onFrame(data) { 'worklet' // 当前帧容器的宽高、进度等信息 const { begin, end, progress, direction } = data ... // 根据图片初始宽高比、共享元素容器、动画进度等计算出变化过程中的值 this.curRect.value = { left = lerp(begin.left, end.left, t), top = lerp(begin.top, end.top, t), width = lerp(begin.width, end.width, t), height = lerp(begin.height, end.height, t), } } 更多共享元素动画原理请查看 官方文档 手势搭配打开图片之后,我们经常需要用到手势来操作图片,如缩放、移动、双击等等 [图片] 我们上次学过的 手势系统 又派上用场啦 通过监听手势事件配合 worklet 函数即可在小程序实现图片预览效果 👇 下面演示缩放手势的处理,除了缩放之外,相册在手势处理上还有很多复杂的逻辑,包括惯性、边界逻辑判断等 点击查看更多相册相关的手势操作 // .wxml // 绑定缩放手势 let sharedValues = this.sharedValues ?? [] // .js // 绑定缩放 this.applyAnimatedStyle('#image', () => { 'worklet' // worklet 函数,sharedValues 变化时,函数会立即执行 return { transform: `scale(${sharedValues[SCALE].value})` } }) // 监听缩放 onScale(evt) { 'worklet' // 连续的手势状态 && 双指放缩 if (evt.state === GestureState.ACTIVE && evt.pointerCount === 2) { // 计算出当前真正的缩放值 sharedValues[SCALE].value = evt.scale / sharedValues[TEMP_LAST_SCALE].value sharedValues[TEMP_LAST_SCALE].value = evt.scale } } 最后,我们来看下小程序实现出来的相册跟原生相册的使用对比,在小程序也可以顺滑的实现类原生的效果啦~ [图片] 目前,同程旅行 已经上线了共享元素结合手势的相册效果,mark这个 相册源码 直接接入到你的小程序吧~ [视频]
2023-08-03 - Skyline|小程序页面转场动画
开发者A:小程序跳转时,页面切换效果可以自定义实现吗? 开发者B:搞个单页自己写呢 开发者A:这个写起来代码量有点大,而且放单页里面代码太复杂了🥹 官方:来啦来啦~小程序页面转场动画可以使用小程序自定义路由来实现,赶紧往下 ⬇️ 看~ 我们先来看下有无自定义路由的效果对比 没有自定义路由:只能使用默认路由切换效果,从右到左推入页面使用自定义路由:支持自定义转场动画,示例下沉式路由效果[图片] 在使用默认路由的时候,只需要调用 wx.navigateTo 即可,而自定义路由,则需要先声明 1、通过 wx.router.addRouteBuilder( 命名,builder 函数 ) 来声明自定义路由 2、页面跳转新增 routeType 参数,值为上一步的命名 [图片] 接着,我们来看下声明自定义路由中 builder 函数,这个函数主要是定义两个动画 定义页面推入动画:结合 推入进度、推入状态 等参数,由开发者自定义计算得来定义页面被推出动画:结合 被推出进度、被推出状态 等参数,由开发者自定义计算得来[图片] 当打开一个新页面的过程中,就会触发自定义路由动画,此时有两个动画 新页面:打开动画旧页面:隐藏动画下图演示在页面 A 打开 页面 B,打开过程的动画效果: 新页面:页面 B 半屏打开旧页面:页面 A 页面下沉[图片] 动画除了上述讲的动画效果之外,还有一个关键的点就是动画曲线,动画曲线可以让动画效果更加丰富。 👇 下面我们可以看出来,相同的动画效果,但是使用不同的动画曲线,展示出来的页面切换效果是很不一样的。worklet 支持了常见的动画缓动函数 Easing,开发者可以根据业务需求实现页面动画切换效果。 [图片] 那么我们就来看一下页面切换动画的代码是怎么实现的~ 页面的切换动画通过 worklet 函数来实现的,worklet 函数运行在 UI 线程,使得小程序可以做到 类原生动画般的体验。 我们先来看下这里设计的新页面打开时的动画,在页面打开的过程中,开发者可以收到一个 primaryAnimation 的参数,这个参数表示当前页面从 0 到 1 打开的进度,此时,我们通过 handlePrimaryAnimation 来实现页面打开过程我们希望页面展示的动画效果,例如:改变页面高度、圆角、与顶部的偏移距离等。 这样,就实现页面展示的动画效果。 // 新页面:页面 B 半屏打开 const handlePrimaryAnimation = () => { 'worklet' // primaryAnimation 为 builder 函数的参数,表示当前页面从 0 - 1 展示动画的进度 // primaryAnimation 为 sharedValue 类型,当 primaryAnimation 变化时,这个函数就会被执行 let t = primaryAnimation.value // 非手势触发时,可以通过动画曲线 easeInToLinear 来改变动画的进度值 // worklet 支持了常见的动画缓动函数,开发者可以根据业务需求实现需要的页面切换动画效果 if (!userGestureInProgress.value) { t = wx.worklet.Easing.bezier(0.35, 0.91, 0.33, 0.97).factory()(t) } const top = 0.12 // 半屏页面距离顶部的距离比例 const selfHeight = (1 - top) * screenHeight // 半屏页面高度 const marginTop = top * screenHeight // 半屏页面距离顶部的距离 const translateY = selfHeight * (1 - t) // 页面动画过程中的纵向偏移值 // 返回 AnimatedStyle,改变页面展示 return { marginTop: `${marginTop}px`, borderRadius: '10px', height: `${selfHeight}px`, transform: `translateY(${translateY}px)`, } } 同样的,页面隐藏跟页面展示是类似的,开发收到的是 secondaryAnimation 参数,表示的是页面从 1 到 0 的关闭进度,这里我们通过 [代码]handleSecondaryAnimation 来实现页面隐藏的效果。[代码] 注意:handleSecondaryAnimation 表示下一页面推入时,当前页面的隐藏动画,在下沉式动画这个案例中,handleSecondaryAnimation 是旧页面的,而 handlePrimaryAnimation 是新页面的。 // 旧页面:页面 A 页面下沉 const handleSecondaryAnimation = () => { 'worklet' // secondaryAnimation 为 builder 函数的参数,表示下一个页面推入时,当前页面从 1 - 0 隐藏动画的进度 // secondaryAnimation 为 sharedValue 类型,当 secondaryAnimation 变化时,这个函数就会被执行 let t = secondaryAnimation.value // 非手势触发时,可以通过动画曲线 fastOutSlowIn 来改变动画的进度值 if (!userGestureInProgress.value) { t = wx.worklet.Easing.bezier(0.4, 0.0, 0.2, 1.0).factory()(t) } const top = 0.1 // 页面距离顶部的距离比例 const scaleRatio = 0.08 // 缩放比例 const translateY = screenHeight * (top - 0.5 * scaleRatio) * t // 页面动画过程中的纵向偏移值 const scale = 1 - scaleRatio * t // 缩放过程中的比例 const radius = 12 * t // 页面圆角 // 返回 AnimatedStyle,改变页面展示 return { borderRadius: `${radius}px`, transform: `translateY(${translateY}px) scale(${scale})`, } } 实现完动画切换的函数之后,我们需要包装到 builder 函数中并注册这个 builder 函数。 // 注册 builder 函数 wx.router.addRouteBuilder("HalfScreenDialog", HalfScreenDialogRouteBuilder) wx.router.addRouteBuilder("ScaleTransition", ScaleTransitionRouteBuilder) // 实现页面 B 的 builder 函数:页面打开时半屏打开 const HalfScreenDialogRouteBuilder = ({ primaryAnimation }) => { return { handlePrimaryAnimation // 页面 B 打开时的动画,上文中实现的 handlePrimaryAnimation 函数 } } // 实现页面 A 的 builder 函数:下一个页面打开时,当前页面下沉 const ScaleTransitionRouteBuilder = ({ primaryAnimation, secondaryAnimation }) => { return { handlePrimaryAnimation, // 页面 A 打开时的动画 handleSecondaryAnimation // 页面 B 隐藏时的动画,上文中实现的 handleSecondaryAnimation 函数 } } 在文章开头我们知道,声明完自定义路由之后,需要在页面跳转时指定路由类型。 到这里,通过页面跳转,返回按钮已经达到我们要的效果了。 // home.js // 首页打开页面 A, wx.navigateTo({ url: 'pageA', routeType: 'ScaleTransition', }) // page.js // 页面 A 打开页面 B wx.navigateTo({ url: 'pageB', routeType: 'HalfScreenDialog', }) 我们在体验原生页面切换时,手势也是顺滑切换的重要组成部分,在上一篇 小程序手势:让半屏弹窗更顺滑 我们已经了解手势的使用,那么我们这里给页面绑定手势,支持向右、向下拖动页面返回。 我们在页面最外层嵌套一个手势组件 horizontal-drag-gesture-handler(横向滑动时触发) 当手势向右滑动时,根据触摸位置改变页面当前的状态 触摸中:页面随手指拖动触摸结束:根据手势速度和位置判断关闭还是打开页面// .wxml // .js // 根据手势状态改变页面展示状态 // this.customRouteContext 中包含当前页面定义路由 builder 时的全部变量 handleHorizontalDrag(gestureEvent) { "worklet"; if (gestureEvent.state === GestureState.BEGIN) { // 触摸开始 const { startUserGesture } = this.customRouteContext; startUserGesture(); } else if (gestureEvent.state === GestureState.ACTIVE) { // 触摸中,实现跟随手指拖动页面效果 const delta = gestureEvent.deltaX / windowWidth; const { primaryAnimation } = this.customRouteContext; const newVal = primaryAnimation.value - delta; primaryAnimation.value = clamp(newVal, 0.0, 1.0); } else if (gestureEvent.state === GestureState.END) { // 触摸结束 const { stopUserGesture, didPop } = this.customRouteContext; ... didPop(); // 退出页面调用 stopUserGesture(); // 结束必须调用 } else if (gestureEvent.state === GestureState.CANCELLED) { // 触摸取消 } } 添加完手势之后,就可以通过手势关闭页面了~ [图片] 除了案例中实现的下沉式半屏效果,自定义路由可以根据开发者需要自行定制动画。 目前,官方提供了几个常用的路由效果供大家使用,mark 这个 代码片段 即可使用。
2023-08-03