鸿蒙HarmonyOS开发:一次开发,多端部署(界面级)天气应用案例
文章目录
- 一、布局简介
- 二、典型布局场景
- 三、侧边栏 SideBarContainer
- 1、子组件
- 2、属性
- 3、事件
- 四、案例 天气应用
- 1、UX设计
- 2、实现分析
- 3、主页整体实现
- 4、具体代码
- 五、运行效果
一、布局简介
布局可以分为自适应布局和响应式布局,二者的介绍如下表所示。
名称 | 简介 |
---|---|
自适应布局 | 当外部容器大小发生变化时,元素可以根据相对关系自动变化以适应外部容器变化的布局能力。相对关系如占比、固定宽高比、显示优先级等。当前自适应布局能力有7种:拉伸能力、均分能力、占比能力、缩放能力、延伸能力、隐藏能力、折行能力。自适应布局能力可以实现界面显示随外部容器大小连续变化。 |
响应式布局 | 当外部容器大小发生变化时,元素可以根据断点、栅格或特定的特征(如屏幕方向、窗口宽高等)自动变化以适应外部容器变化的布局能力。当前响应式布局能力有3种:断点、媒体查询、栅格布局。响应式布局可以实现界面随外部容器大小有不连续变化,通常不同特征下的界面显示会有较大的差异。 |
自适应布局多用于解决页面各区域内的布局差异,响应式布局多用于解决页面各区域间的布局差异。
自适应布局和响应式布局常常需要借助容器类组件实现,或与容器类组件搭配使用。
- 自适应布局常常需要借助Row组件、Column组件或Flex组件实现。
- 响应式布局常常与GridRow组件、Grid组件、List组件、Swiper组件或Tabs组件搭配使用。
二、典型布局场景
虽然不同应用的页面千变万化,但对其进行拆分和分析,页面中的很多布局场景是相似的。本小节将介绍如何借助自适应布局、响应式布局以及常见的容器类组件,实现应用中的典型布局场景。
布局场景 | 实现方案 |
---|---|
页签栏 | Tab组件 + 响应式布局 |
运营横幅(Banner) | Swiper组件 + 响应式布局 |
网格 | Grid组件 / List组件 + 响应式布局 |
侧边栏 | SideBar组件 + 响应式布局 |
单/双栏 | Navigation组件 + 响应式布局 |
三分栏 | SideBar组件 + Navigation组件 + 响应式布局 |
自定义弹窗 | CustomDialogController组件 + 响应式布局 |
大图浏览 | Image组件 |
操作入口 | Scroll组件+Row组件横向均分 |
顶部 | 栅格组件 |
缩进布局 | 栅格组件 |
挪移布局 | 栅格组件 |
重复布局 | 栅格组件 |
三、侧边栏 SideBarContainer
提供侧边栏可以显示和隐藏的侧边栏容器,通过子组件定义侧边栏和内容区,第一个子组件表示侧边栏,第二个子组件表示内容区。
1、子组件
- 子组件类型:系统组件和自定义组件,不支持渲染控制类型(if/else、ForEach和LazyForEach)。
- 子组件个数:必须且仅包含2个子组件。
- 子组件个数异常时:3个或以上子组件,显示第一个和第二个。1个子组件,显示侧边栏,内容区为空白。
2、属性
名称 | 参数类型 | 描述 |
---|---|---|
showSideBar | boolean | 设置是否显示侧边栏。 默认值:true |
controlButton | ButtonStyle | 设置侧边栏控制按钮的属性。 |
showControlButton | boolean | 设置是否显示控制按钮。 默认值:true |
sideBarWidth | number | Length9+ |
sideBarPosition | SideBarPosition | 设置侧边栏显示位置。 默认值:SideBarPosition.Start |
3、事件
onChange(callback: (value: boolean) => void)
当侧边栏的状态在显示和隐藏之间切换时触发回调。true表示显示,false表示隐藏。
触发该事件的条件:
1、showSideBar属性值变换时;
2、showSideBar属性自适应行为变化时;
3、分割线拖拽触发autoHide时。
四、案例 天气应用
通过一个天气应用,介绍一多应用的整体开发过程。
“一多”建议从最初的设计阶段开始就拉通多设备综合考虑。考虑实际智能终端设备种类繁多,设计师无法针对每种具体设备各自出一份UX设计图。“一多”建议从设备屏幕宽度的维度,将设备划分为三大类。设计师只需要针对这三大类设备做设计,而无需关心具体的设备形态。
1、UX设计
默认设备和平板对应于小设备、中设备及大设备,本示例以这三类设备场景为例,介绍不同设备上的UX设计。天气主页在不同设备上的设计图如下所示。
另外,大设备中天气主页还允许用户开启或者隐藏侧边栏。
2、实现分析
将天气主页划分为9个基础区域
天气主页中的9个基础区域介绍及实现方案如下表所示。
编号 | 简介 | 实现方案 |
---|---|---|
1 | 标题栏 | 自适应布局拉伸能力。 |
2 | 天气概览 | Row和Column组件,并指定其子组件按照主轴起始方向对齐或居中对齐。 |
3 | 每小时天气 | 自适应布局延伸能力 。 |
4 | 每日天气 | 自适应布局延伸能力 。 |
5 | 空气质量 | Canvas画布组件绘制空气质量图,并使用Row组件和Column组件控制内部元素的布局。 |
6 | 生活指数 | Grid响应式布局。 |
7 | 日出日落 | Canvas画布组件绘制日出日落图 。 |
8 | 应用信息 | Row和Column组件,并指定其子组件居中对齐。 |
9 | 侧边导航栏 | 综合运用自适应布局中的拉伸能力、占比能力和延伸能力 。 |
天气主页右侧的城市天气详情由区域1-8组成,区域1(标题栏)始终固定在页面顶部,区域2-8在不同设备下的布局不同且可以随页面上下滚动。本小节介绍如何实现城市天气详情中区域2~8的布局效果。
设备屏幕可能无法一次性显示区域2-8的所有内容,故需要在外层增加滚动组件(即Scroll组件)以支持上下滚动。不同设备下区域2-8的相对位置一共有三套不同的布局,可以借助响应式布局中的栅格布局实现这一效果。本示例中将栅格在不同场景下分别划分为4列、8列和12列,区域2-8在不同场景下的布局如下表所示。
为提升用户体验,大设备侧边栏隐藏状态下,每日天气与空气质量的相对顺序发生了改变。可以通过调整GridCol栅格子组件的order属性,实现目标效果。
3、主页整体实现
综合考虑各设备下的效果,天气主页的根节点使用侧边栏组件:
-
小设备和中设备既不展示侧边栏,也不提供控制侧边栏显示和隐藏的按钮。
-
大设备默认展示侧边栏,同时提供控制侧边栏显示和隐藏的按钮。
另外主页右侧的城市天气详情,支持左右滑动切换城市,可以使用Swiper组件实现目标效果。
-
小设备和中设备开启Swiper组件的导航点,引导用户通过左右滑动切换不同城市。
-
大设备中用户通过点击侧边栏中的城市列表即可高效的切换不同城市,此时需要关闭Swiper组件的导航点。
4、具体代码
代码里数据均采用测试数据,只是为了布局展示,没有详细数据模型。
图片icon只是采用了简单的代替。
- 主页侧边栏布局
// Weather.ets
import HomeContent from './weather/HomeContent';
import IndexTitleBar from './weather/IndexTitleBar';
import SideContent from './weather/SideContent';
import { BreakpointSystem } from '../utils/BreakpointSystem'
@Entry
@Component
struct Home {
@State showSideBar: boolean = false;
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm'
private breakpointSystem: BreakpointSystem = new BreakpointSystem();
aboutToAppear() {
// 注册监听
this.breakpointSystem.register()
}
aboutToDisappear() {
// 移除监听
this.breakpointSystem.unregister()
}
build() {
SideBarContainer(SideBarContainerType.Embed) {
// 左侧侧边栏
SideContent({ showSideBar: $showSideBar })
// 右侧内容区
Flex({ direction: FlexDirection.Column }) {
// 基础区域1标题栏
IndexTitleBar({ showSideBar: $showSideBar })
// 天气详情,通过Swiper组件实现左右滑动切换城市的效果
Swiper() {
ForEach([1, 2, 3], (item, index) => {
HomeContent({ showSideBar: this.showSideBar })
})
}
// 大设备关闭导航点
.indicator(this.currentBreakpoint !== 'lg')
.width('100%')
}
.backgroundImage($r('app.media.whiteClouds'))
.backgroundImageSize(ImageSize.Cover)
}
.height('100%')
.sideBarWidth('34%')
// 通过状态变量,控制不同设备下侧边栏的显隐状态
.showSideBar(this.currentBreakpoint == 'lg' ? this.showSideBar : false)
.showControlButton(this.currentBreakpoint == 'lg')
.controlButton({
left: this.showSideBar ? 232 : 10,
top: 10,
width: 30,
height: 20,
icons: {
switching: $r('app.media.switching'),
shown: $r('app.media.switching'),
hidden: $r('app.media.switching')
}
})
.onChange((value: boolean) => {
this.showSideBar = value
})
.backgroundColor('#116ACE')
}
}
- 左边栏布局9
// SideContent.ets
@Component
export default struct SideContent {
@Prop showSideBar: boolean;
build() {
Column({ space: 10 }) {
Text('天气')
.fontColor(Color.White)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.width('100%')
.margin({ top: 40 })
Search({ placeholder: '搜索城市(中文/拼音)' })
.backgroundColor('rgba(255, 255, 255, 0.2)')
.placeholderColor(Color.White)
.placeholderFont({ size: 14 })
.width('100%')
.textFont({ size: 14 })
List({ space: 12 }) {
ForEach([1, 2, 3, 4, 5], (item) => {
ListItem() {
Row() {
Text('深圳')
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.fontSize(20)
Blank()
Column() {
Row({ space: 2 }) {
Text('20')
.fontColor(Color.White)
.fontSize(32)
Text('℃')
.fontColor(Color.White)
.fontSize(16)
.margin({ top: 6 })
}.alignItems(VerticalAlign.Top)
Text('晴')
.fontColor(Color.White)
.fontSize(14)
}
}.width('100%')
.backgroundColor('rgba(255, 255, 255, 0.1)')
.padding(6)
.borderRadius(8)
}
})
}
}.width('100%')
.height('100%')
.padding(18)
.backgroundColor('rgba(255, 255, 255, 0.1)')
}
}
- 右边栏整体布局
// HomeContent.ets
import AirQuality from './AirQuality';
import HoursWeather from './HoursWeather';
import IndexEnd from './IndexEnd';
import IndexHeader from './IndexHeader';
import LifeIndex from './LifeIndex';
import MultidayWeather from './MultidayWeather';
import SunCanvas from './SunCanvas';
@Component
export default struct HomeContent {
@Prop showSideBar: boolean;
build() {
// 支持滚动
Scroll() {
GridRow({
columns: { sm: 4, md: 8, lg: this.showSideBar ? 8 : 12 },
breakpoints: { reference: BreakpointsReference.WindowSize } }) {
// 天气概览2
GridCol({ span: { sm: 4, md: 8, lg: this.showSideBar ? 8 : 12 }, order: 1 }) {
IndexHeader()
}
// 每小时天气3
GridCol({ span: { sm: 4, md: 8, lg: 8 }, order: 2 }) {
HoursWeather()
}
// 每日天气4
GridCol({ span: 4, order: { sm: 3, md: 3, lg: this.showSideBar ? 3 : 4 } }) {
MultidayWeather()
}
// 空气质量5
GridCol({ span: 4, order: { sm: 4, md: 4, lg: this.showSideBar ? 4 : 3 } }) {
AirQuality()
}
// 生活指数6
GridCol({ span: 4, order: 5 }) {
LifeIndex()
}
// 日出日落7
GridCol({ span: 4, order: 6 }) {
SunCanvas()
}
// 应用信息8
GridCol({ span: { sm: 4, md: 8, lg: this.showSideBar ? 8 : 12 }, order: 7 }) {
IndexEnd()
}
}
}
.width('100%')
}
}
- 标题栏布局1
// IndexTitleBar.ets
@Component
export default struct IndexTitleBar {
@Prop showSideBar: boolean;
build() {
Row() {
Image($r('app.media.menu'))
.width(24)
.height(24)
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.End)
.padding(10)
}
}
- 天气概览布局2
// IndexHeader.ets
@Component
export default struct IndexHeader {
build() {
Column({ space: 4 }) {
Row({ space: 4 }) {
Text('浦东新区')
.fontColor(Color.White)
.fontSize(18)
Image($r('app.media.location2'))
.width(18)
.height(18)
}.margin({ top: 4 })
Row({ space: 4 }) {
Text('20')
.fontColor(Color.White)
.fontSize(48)
Text('℃')
.fontColor(Color.White)
.fontSize(16)
.margin({ top: 8 })
}.alignItems(VerticalAlign.Top)
Row({ space: 4 }) {
Text('22℃ / 15℃')
.fontColor(Color.White)
.fontSize(14)
}
Row({ space: 4 }) {
Text('晴 空气优')
.fontColor(Color.White)
.fontSize(14)
}
Blank()
Row({ space: 10 }) {
Image($r('app.media.notice'))
.width(14)
.height(14)
Text('当前多云,今天和昨天温度一样,适合外出游玩!')
.fontColor(Color.White)
.fontSize(12)
}.width('100%').justifyContent(FlexAlign.Start)
}.width('100%')
.height(180)
.padding(10)
}
}
- 每小时天气布局3
// HoursWeather.ets
@Component
export default struct HoursWeather {
build() {
Row() {
List({ space: 2 }) {
ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], (item) => {
ListItem() {
Column({ space: 8 }) {
Text(`上午 ${item}:00`).fontSize(12).fontColor(Color.White)
Image($r('app.media.w7')).width(30).height(30).objectFit(ImageFit.Contain)
Text(`${item}℃`).fontSize(12).fontColor(Color.White)
Text('东风').fontSize(12).fontColor(Color.White)
Text(`${item}级`).fontSize(12).fontColor(Color.White)
}.width(84)
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
})
}
.listDirection(Axis.Horizontal)
.backgroundColor('rgba(255, 255, 255, 0.1)')
.padding(5)
.borderRadius(6)
.width('100%')
}.width('100%')
.height(150)
.padding(6)
}
}
- 每日天气布局4
// MultidayWeather.ets
@Component
export default struct MultidayWeather {
build() {
Row() {
List({ space: 2 }) {
ForEach([1, 2, 3, 4, 5, 6, 7], () => {
ListItem() {
Column({ space: 4 }) {
Text('昨日').fontSize(12).fontColor(Color.White)
Text('09/12').fontSize(12).fontColor(Color.White)
Image($r('app.media.w5')).width(26).height(26).objectFit(ImageFit.Contain)
Text('晴').fontSize(12).fontColor(Color.White)
Text('16℃/22℃').fontSize(12).fontColor(Color.White)
Button() {
Text('良').fontSize(10).fontColor(Color.White)
}.backgroundColor('rgba(255, 255, 255, 0.2)').padding({ top: 2, bottom: 2, left: 6, right: 6 })
}.width(84)
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
})
}
.listDirection(Axis.Horizontal)
.backgroundColor('rgba(255, 255, 255, 0.1)')
.padding(5)
.borderRadius(6)
.width('100%')
.height('100%')
}.width('100%')
.height(150)
.padding(6)
}
}
- 空气质量布局5
// AirQuality.ets
@Component
export default struct AirQuality {
build() {
Row() {
Row() {
Column() {
Image($r('app.media.meteorological')).height('88%').objectFit(ImageFit.Contain)
}.width('55%').height('100%').alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Row() {
Text('PM10').fontColor(Color.White).fontSize(12).width('40%').opacity(0.8)
Text('181').fontColor(Color.White).fontSize(12).layoutWeight(1)
}
Row() {
Text('PM2.5').fontColor(Color.White).fontSize(12).width('40%').opacity(0.8)
Text('43').fontColor(Color.White).fontSize(12).layoutWeight(1)
}
Row() {
Text('NO2').fontColor(Color.White).fontSize(12).width('40%').opacity(0.8)
Text('66').fontColor(Color.White).fontSize(12).layoutWeight(1)
}
Row() {
Text('SO2').fontColor(Color.White).fontSize(12).width('40%').opacity(0.8)
Text('121').fontColor(Color.White).fontSize(12).layoutWeight(1)
}
Row() {
Text('O2').fontColor(Color.White).fontSize(12).width('40%').opacity(0.8)
Text('8').fontColor(Color.White).fontSize(12).layoutWeight(1)
}
Row() {
Text('CO').fontColor(Color.White).fontSize(12).width('40%').opacity(0.8)
Text('6').fontColor(Color.White).fontSize(12).layoutWeight(1)
}
}
.width('45%')
.height('100%')
.padding(4)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.SpaceBetween)
}
.backgroundColor('rgba(255, 255, 255, 0.1)')
.padding(5)
.borderRadius(6)
.width('100%')
.height('100%')
}.width('100%')
.height(150)
.padding(6)
}
}
- 生活指数布局6
// LifeIndex.ets
@Component
export default struct LifeIndex {
build() {
Row() {
Row() {
Grid() {
ForEach([1, 2, 3, 4, 5, 6, 7, 8], (item) => {
GridItem() {
Column() {
Image($r('app.media.w9')).width(26).height(26).objectFit(ImageFit.Contain)
Text('昨日').fontSize(12).fontColor(Color.White)
Text('晴' + item).fontSize(12).fontColor(Color.White)
}
}
})
}.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')
.columnsGap(2)
.rowsGap(2)
}
.backgroundColor('rgba(255, 255, 255, 0.1)')
.padding(5)
.borderRadius(6)
.width('100%')
.height('100%')
}.width('100%')
.height(150)
.padding(6)
}
}
- 日出日落布局7
// SunCanvas.ets
@Component
export default struct SunCanvas {
build() {
Row() {
Column() {
Image($r('app.media.sunset')).height('88%').objectFit(ImageFit.Contain)
}
.backgroundColor('rgba(255, 255, 255, 0.1)')
.padding(5)
.borderRadius(6)
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}.width('100%')
.height(150)
.padding(6)
}
}
- 应用信息布局8
// IndexEnd.ets
@Component
export default struct IndexEnd {
build() {
Row() {
Column() {
Text('中国天气').fontSize(12).fontColor(Color.White)
Text('天气版本:11.1.0').fontSize(12).fontColor(Color.White)
Text('关于天气与隐私的声明').fontSize(12).fontColor(Color.White)
Text('版权所有©2018 华为技术有限公司保留一切权利').fontSize(12).fontColor(Color.White)
}
.backgroundColor('rgba(255, 255, 255, 0.1)')
.padding(8)
.borderRadius(6)
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.SpaceEvenly)
}.width('100%')
.height(120)
.padding(6)
}
}
五、运行效果