一多开发实例(便捷生活)概述
本文从目前流行的垂类市场中,选择便捷生活类应用作为典型案例详细介绍“一多”在实际开发中的应用。一多便捷生活应用包含首页展示、商品展示、图文详情、视频浏览等功能。根据这些核心功能,本文选择美食列表页、店铺页、商品详情页、图文详情页、视频页等作为典型页面进行开发,遵从多设备的“差异性”、“一致性”、“灵活性”和“兼容性”,能够让开发者快速高效地掌握“一多”能力并实现便捷生活应用的相关功能。
便捷生活类应用为了向用户展示更多的商品选择,对垂类内的核心功能进行了独特设计:
- 店铺页,多端适配不同形态的弹窗用以进行商品规格的选择,贴合用户交互习惯。
- 商品详情页,使用滑动伸缩的方式展示商品图,突出商品样式,解决多端大图展示问题。
- 图文详情页,以多种形式实现“一多”布局并加入图片放大和沉浸式浏览的交互设计,让用户有更好的浏览体验。
- 视频详情页,通过模糊显示直播背景,让页面主题统一,实现沉浸式观看。
UX设计
便捷生活类的多设备响应式设计指南。
架构设计
HarmonyOS的分层架构主要包括三个层次:产品定制层、基础特性层和公共能力层,为开发者构建了一个清晰、高效、可扩展的设计架构。
页面开发
本章介绍便捷生活应用中如何使用“一多”的布局能力,完成页面层级的一套页面、多端适配。下文将从不同页面展开,介绍每个页面区域使用到具体的布局能力,帮助开发者从0到1进行购物比价应用的开发。
布局能力
本节由不同页面展开,介绍每个页面区域使用到的具体布局能力,帮助开发者从零到一进行便捷生活应用的开发。
首页
首页通常有入口图标和商品卡片等丰富的页面跳转入口和商品推荐信息,帮助解决用户浏览及挑选商品的核心需求。观察首页在不同设备上的UX设计图,可以进行如下设计:
- 将首页划分为5个区域,效果图如下:
示意图
sm
md
lg
首页
首页的5个基础区域介绍及实现方案如下表所示:
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸,在lg断点采用Blank组件填充中间空白区域。
2
菜单列表
使用网格容器,借助栅格组件能力监听断点变化改变列数,设置aspectRatio属性实现缩放能力。
3
秒杀列表
List组件实现延伸能力。
4
商品列表
使用WaterFlow容器,实现一列到多列的切换。在sm断点下依赖断点控制设置WaterFlow的columsTemplate属性为2,在md断点下设置columsTemplate为3,在lg断点下设置columsTemplate为4。
5
菜单导航栏
借助栅格布局监听断点变化改变位置。
美食列表
美食列表页显示推荐美食,在大屏上增多列数的布局以增加用户信息量获取。观察美食列表页在不同设备上的UX设计图,可以进行如下设计:
- 将美食列表页划分为3个区域,效果图如下:
示意图
sm
md
lg
美食列表页
美食列表页的3个基础区域介绍及实现方案如下表所示:
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸,在lg断点采用Blank组件填充中间空白区域。
2
菜单列表
List组件实现延伸能力。
3
美食列表
响应式布局的栅格布局,设置aspectRatio属性实现缩放能力。
店铺页
店铺页展示店铺信息和其所售卖的所有商品,可以在侧边栏查看并且快速切换,用户也可以选择商品规格,在不同产品中弹窗以不同形态显示,贴合操作习惯。观察店铺页在不同设备上的UX设计图,可以进行如下设计:
- 将店铺页划分为4个区域,效果图如下:
示意图
sm
md
lg
店铺页
店铺页-侧边栏
店铺页-选规格
店铺页的4个基础区域介绍及实现方案如下表所示:
编号
简介
实现方案
1
店铺信息展示区
在父元素上使用Flex组件实现挪移布局和visibility属性实现样式切换。
2
菜单列表
使用Tabs组件嵌套Scroll组件实现顶部页签嵌套列表。
3
购物车
使用拉伸能力结合断点控制元素尺寸,在lg断点采用Blank组件填充中间空白区域。
4
选规格弹窗
使用BindSheet属性和PopUp属性实现不同设备上的弹窗显示。
- 店铺信息展示区使用Flex属性的direction属性根据断点切换上下或者左右布局,使用visibility属性根据断点切换显隐。
// entry/src/main/ets/pages/ShopDisplay Flex({ direction: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG && !this.ifShowSides ? FlexDirection.Row : FlexDirection.Column, justifyContent: FlexAlign.Start }) { ShopHeader() .visibility(this.currentBreakpoint !== BreakpointConstants.BREAKPOINT_LG || this.ifShowSides ? Visibility.Visible : Visibility.None) ShopSideBar() .width(CommonConstants.THIRTY_SEVEN_PERCENT) .flexShrink(0) .visibility(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG && !this.ifShowSides ? Visibility.Visible : Visibility.None) ShopOrderList() .height(CommonConstants.FULL_PERCENT) // ... } // ...
- 菜单列表使用Tabs嵌套Scroll组件实现菜单页签切换。
// entry/src/main/ets/view/ShopOrderList // Tabs显示上方页签 Tabs({ controller: this.topTabsController }) { ForEach(this.tabsList, () => { TabContent() { ShopMenu().width(CommonConstants.FULL_PERCENT) } }) } // ... // entry/src/main/ets/view/ShopMenu Row() { Column() { // .. } // ... Scroll(this.scroller) { Column(){ // ... } } // 以nestedScroll实现滑动嵌套效果 .nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST }) // ... }
- 选规格弹窗在sm和md时使用bindSheet(半模态转场)组件实现,在lg规格屏幕使用PopUp实现跟手弹窗。
// entry/src/main/ets/view/ShopDish Text('选规格') .onClick(() => { if (this.currentBreakpoint !== BreakpointConstants.BREAKPOINT_LG) { this.showPop = true; } else { this.showPopUp = true; this.showPopUpChange = true; } }) .bindSheet($$this.showPop, this.popBuilder(), { height: SheetSize.FIT_CONTENT, backgroundColor: Color.White, title: { title: $r('app.string.select_specification') }, maskColor: $r('app.color.forty_black') }) .bindPopup(this.showPopUp, { builder: this.popBuilder, placement: Placement.Left, width: $r('app.float.popup_width'), mask: { color: $r('app.color.forty_black') }, onStateChange: (e) => { if (!e.isVisible) { this.showPopUp = false; } } }) // ...
商品详情
商品详情页展示商品具体信息,加入了可以通过上下滑动查看完整商品缩略图的交互设计,商品全貌展现更加直观,也可以使用侧边栏查看商品,交互更加便捷。观察商品详情页在不同设备上的UX设计图,可以进行如下设计:
- 将商品详情页划分为3个区域,效果图如下:
示意图
sm
md
lg
商品详情页
商品详情页-侧边栏
商品详情页的3个基础区域介绍及实现方案如下表所示:
编号
简介
实现方案
1
商品信息展示区
通过绑定onScrollFrameBegin监听滑动改变图片高度实现上下滑动查看完整缩略图的交互效果。
2
商品信息区
Column组件实现,内部使用拉伸能力结合断点控制元素尺寸,同时采用Blank组件填充中间空白区域。
3
购物车
使用拉伸能力结合断点控制元素尺寸,同时采用Blank组件填充中间空白区域。
- 效果图-交互动画(上下滑动查看完整缩略图)通过绑定onScrollFrameBegin监听滑动改变图片高度:
// entry/src/main/ets/pages/DishDetails @State ifPictureExpansion: Boolean = false @State imageHeightExtension: number = 0 @State imageHeightFold: number = 0 @State imageHeight: number = 0 // ... Scroll(this.informationScroller) { GridRow({ columns: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG && !this.ifShowSides ? 12 : 1 }) { GridCol({ span: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG && !this.ifShowSides ? 6 : 1 }) { if (this.currentBreakpoint !== BreakpointConstants.BREAKPOINT_LG || this.ifShowSides) { DishHead({ ifPictureExpansion: this.ifPictureExpansion, imageHeightExtension: this.imageHeightExtension, imageHeightFold: this.imageHeightFold, imageHeight: this.imageHeight }) } else { DishSideBar() } } // ... } } // 监听scroll当下滑时展开,上滑时收起 .onScrollFrameBegin((offset: number, state: ScrollState) => { if (!this.ifPictureExpansion && offset < 0) { this.imageHeight = this.imageHeightExtension; this.ifPictureExpansion = true; return { offsetRemain: 0 }; } else if (this.ifPictureExpansion && offset > 0) { this.imageHeight = this.imageHeightFold; this.ifPictureExpansion = false; return { offsetRemain: 0 }; } else { return { offsetRemain: offset }; } }) // ... // entry/src/main/ets/view/DishInformation // ... @Link @Watch('resizeImage') ifPictureExpansion: Boolean; @Link imageHeightExtension: number; @Link imageHeightFold: number; @Link imageHeight: number; @State @Watch('resizeImage') imageRatio: number = 0; @State @Watch('resizeImage') imageWidth: number = 0; // 监听ifPictureExpansion改变图片高度 resizeImage(): void { this.imageHeightExtension = this.imageWidth * this.imageRatio; this.imageHeightFold = Math.min(380, this.imageHeightExtension); if (this.ifPictureExpansion) { this.imageHeight = this.imageHeightExtension; } else { this.imageHeight = this.imageHeightFold; } } // ... Image($r('app.media.merchandiseMap')) .width('100%') .height(this.imageHeight) .objectFit(ImageFit.Cover) // 图片高度变化动画 .animation({ duration: 250, curve: Curve.EaseOut, iterations: 1, playMode: PlayMode.Normal }) .onComplete((msg) => { this.imageRatio = msg!.height / msg!.width; }) .onAreaChange((oldArea: Area, newArea: Area) => { this.imageWidth = Number.parseInt(JSON.stringify(newArea.width)); if (this.ifPictureExpansion === false) { this.imageHeight = this.imageHeightFold; } else { this.imageHeight = this.imageHeightExtension; } })
微详情页
微详情页显示推荐的商品信息,在不同设备上以不同列数呈现,增加页面呈现的信息量。观察微详情页在折叠屏上的UX设计图,可以进行如下设计:
- 效果图如下:
示意图 | sm | md | lg |
---|---|---|---|
微详情页 |
简介 | 实现方案 |
---|---|
微详情页 | 使用WaterFlow容器,实现一列到多列的切换。在sm断点下依赖断点控制设置WaterFlow的columsTemplate属性为1,在md断点下设置columsTemplate为2,在lg断点下设置columsTemplate为3。 |
电影列表页
电影列表页展示推荐的电影信息,我们为lg规格的屏幕提供了三种设计范式,读者可自行选择参考。观察电影列表页页在不同设备上的UX设计图,可以进行如下设计:
- 将电影列表页划分为3个区域,效果图如下:
示意图
sm
md
lg
电影列表页-范式1
电影列表页-范式2
电影列表页-范式3
电影列表页的3个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸,同时采用Blank组件填充中间空白区域。
2
即将上映
List组件实现延伸能力,通过listDirection调整方向
3
正在热映
使用WaterFlow容器,实现一列到多列的切换。在sm断点下依赖断点控制设置WaterFlow的columsTemplate属性为1,在md断点下设置columsTemplate为2,在lg断点下设置columsTemplate为3。
电影简介页
电影简介页展示电影的具体信息,在lg规格的屏幕上采用了左右布局,以充分利用空间。观察电影简介页在不同设备上的UX设计图,可以进行如下设计:
- 将电影简介页划分为3个区域,效果图如下:
示意图
sm
md
lg
电影简介页
电影简介页的3个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸。
2
电影信息
利用响应式布局的栅格布局,使用Grid组件实现挪移布局,设置aspectRatio属性实现缩放能力。
3
电影详情区
使用tabs嵌套coloum,不同模块标题使用拉伸能力结合断点控制元素尺寸,同时采用Blank组件填充中间空白区域。
选影院页
选影院页展示影院列表以供用户选择,并提供电影海报预览方便切换。观察选影院页在不同设备上的UX设计图,可以进行如下设计:
- 将选影院页划分为3个区域,效果图如下:
示意图
sm
md
lg
选影院页-范式1
选影院页的3个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸。
2
电影海报
Swiper组件,指定displayCount属性实现占比能力,设置aspectRatio属性实现缩放能力。
3
电影列表
使用Tabs组件+List组件,实现重复布局。
首页-推荐页
首页-推荐页展示向用户推荐的图文简略信息,在不同设备上以不同列数呈现,增加信息量呈现,观察首页-推荐页在不同设备上的UX设计图,可以进行如下设计:
- 将首页-推荐页划分为3个区域,效果图如下:
示意图
sm
md
lg
首页-推荐页
推荐页的3个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸,同时采用Blank组件填充中间空白区域。
2
推荐展示区
使用WaterFlow容器,实现一列到多列的切换。在sm断点下依赖断点控制设置WaterFlow的columsTemplate属性为2,在md断点下设置columsTemplate为3,在lg断点下设置columsTemplate为4。
3
菜单导航栏
借助栅格布局监听断点变化改变位置。
首页-关注页
首页-关注页展示用户的关注列表和以及其最新发布的图文信息,我们提供了三种范式,读者可自行选择参考。观察首页-关注页在不同设备上的UX设计图,可以进行如下设计:
- 将首页-关注页划分为4个区域,效果图如下:
示意图
sm
md
lg
首页-关注页-范式1
首页-关注页-范式2
首页-关注页-范式3
电影简介页的4个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸,同时采用Blank组件填充中间空白区域。
2
关注列表
List组件实现延伸能力。
3
关注详情
使用List组件实现重复布局。
4
菜单导航栏
借助栅格布局监听断点变化改变位置。
- 将首页-关注页划分为4个区域,效果图如下:
短视频详情页
短视频详情页进行视频播放,并相关提供功能按钮,提供边看视频边看评论的页面设计。观察短视频页在不同设备上的UX设计图,可以进行如下设计:
- 将短视频页划分为4个区域,效果图如下:
示意图
sm
md
lg
短视频详情页-范例1
短视频详情页-范例2
侧边栏-评论
短视频详情页-标签页信息栏
电影简介页的4个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
短视频展示区
使用Stack容器组件实现Video组件和Text组件、Image组件的堆叠效果,其中Video组件使用.align(Alignment.Center)实现居中
2
菜单导航栏
借助栅格布局监听断点变化改变位置。
3
视频评论区
使用List组件实现重复布局,在sm规格使用bindSheet实现半模态,在md和lg规格下使用Row组件呈左右布局。
4
标签页信息栏
使用List组件实现重复布局。
直播页
直播页进行直播播放并展示用户评论,背景使用图片模糊带给用户更沉浸的观看体验。观察直播页在不同设备上的UX设计图,可以进行如下设计:
- 将电影列表页划分为3个区域,效果图如下:
示意图
sm
md
lg
直播页
电影简介页的3个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
直播区
使用Stack容器组件实现Video组件和Text组件、Image组件的堆叠效果,其中Video组件使用.align(Alignment.Center)实现居中,背景模糊效果参考下方代码。
2
评论区
使用List组件实现重复布局。
3
评论输入区域
使用TextInput组件实现。
- 直播区-背景模糊
// entry/src/main/ets/pages/Living SideBarContainer(SideBarContainerType.Embed) { LivingComments() .width(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG?'40%':'37.5%') LivingHome() } .backgroundImage($r('app.media.fm2_img')) // 设置背景模糊 .backgroundBlurStyle(BlurStyle.BACKGROUND_THICK, { colorMode: ThemeColorMode.DARK, adaptiveColor: AdaptiveColor.DEFAULT }) .backgroundImageSize({ width: '100%', height: "100%" }) // ...
图文详情页
图文详情页展示商品具体信息。观察图文详情页在不同设备上的UX设计图,可以进行如下设计:
- 将图文详情页划分为7个区域,效果图如下:
示意图
sm
md
lg
图文详情页
图文详情页-上图下文
侧边栏-商品详情
侧边栏-个人主页
图文详情页的7个基础区域介绍及实现方案如下表所示
编号
简介
实现方案
1
顶部功能区
使用拉伸能力结合断点控制元素尺寸,同时采用Blank组件填充中间空白区域。
2
图片展示区
Swiper组件,设置aspectRatio属性实现缩放能力
3
文章详情
使用Coloum组件展示文章详情。
4
底部功能区
使用拉伸能力结合断点控制元素尺寸。
5
评论区
使用TextInput组件实现。
6
商品详情
使用Coloum组件,设置aspectRatio属性实现缩放能力。
7
个人主页
使用List组件实现重复布局。
- 上图下文有三种范式,可以通过调节swiper展示
范式一
范式二
范式三
- 交互效果
点击缩放
双指滑动缩放
上滑沉浸式浏览
// entry/src/main/ets/view/GraphicTextSwiper Image(item) // 点击放大 .onClick(() => { animateTo({ duration: CommonConstants.ANIMATE_DURATION, curve: Curve.Friction }, () => { this.isFullScreen = true; this.fullImageIndex = index; }); }) // 双指 .gesture( PinchGesture({ fingers: 2 }) .onActionUpdate((event: GestureEvent) => { animateTo({ duration: CommonConstants.ANIMATE_DURATION, curve: Curve.Friction }, () => { this.isFullScreen = true; this.fullImageIndex = index; }); }) ) // ...