1.介绍
本篇Codelab基于自适应布局和响应式布开发的一款音乐专辑软件,功能主要是一些音乐播放器的普通功能:播放音乐、上一首、下一首、暂停播放、歌单评论等,手机效果如图所示:
2 环境搭建
1.软件要求
• DevEco Studio版本:DevEco Studio NEXT Developer Beta1及以上。
• HarmonyOS SDK版本:HarmonyOS NEXT Developer Beta1 SDK及以上。
2.硬件要求
• 设备类型:华为手机。
• HarmonyOS系统:HarmonyOS NEXT Developer Beta1及以上。
项目创建如下:
3 代码结构解读
├──common // 公共能力层
│ ├──constantsCommon/src/main/ets/constants // 公共常量层
│ │ ├──BreakpointConstants.ets // 断点常量类
│ │ ├──GridConstants.ets // 栅格常量类
│ │ ├──RouterUrlConstants.ets // 地址常量类
│ │ ├──SongConstants.ets // 音乐常量类
│ │ └──StyleConstants.ets // 样式常量类
│ └──mediaCommon/src/main/ets // 公共方法层
│ ├──utils
│ │ ├──BackgroundUtil.ets // 后台任务工具类
│ │ ├──BreakpointSystem.ets // 断点工具类
│ │ ├──ColorConversion.ets // 颜色转换工具类
│ │ ├──Logger.ets // 日志工具类
│ │ ├──MediaService.ets // 音乐操作工具类
│ │ ├──MediaTools.ets // 音乐时间工具类
│ │ ├──PreferencesUtil.ets // 数据存储工具类
│ │ └──SongItemBuilder.ets // 歌曲构造工具类
│ └──viewmodel
│ ├──CardData.ets // 卡片数据实体类
│ ├──MenuData.ets // 菜单数据实体类
│ ├──MusicData.ets // 状态和类型数据类
│ └──SongData.ets // 歌曲数据实体类
├──features // 基础特性层
│ ├──live/src/main/ets // 直播页面
│ │ ├──constants
│ │ │ └──LiveConstants.ets // 直播常量类
│ │ ├──view
│ │ │ ├──Header.ets // 顶部标题栏
│ │ │ ├──LiveList.ets // 直播列表
│ │ │ └──LivePage.ets // 直播主页面
│ │ └──viewmodel
│ │ ├──LiveStream.ets // 直播数据实体类
│ │ └──LiveStreamViewModel.ets // 直播列表数据类
│ ├──live/src/main/resources // 资源文件目录
│ ├──musicComment/src/main/ets // 评论页面
│ │ ├──constants
│ │ │ └──CommonConstants.ets // 评论常量类
│ │ ├──view
│ │ │ ├──HeadComponent.ets // 顶部标题栏
│ │ │ ├──ListItemComponent.ets // 评论列表
│ │ │ ├──MusicCommentPage.ets // 音乐评论主页面
│ │ │ └──MusicInfoComponent.ets // 歌曲信息简介
│ │ └──viewmodel
│ │ ├──Comment.ets // 评论数据实体类
│ │ └──CommentViewModel.ets // 评论列表数据类
│ ├──musicComment/src/main/resources // 资源文件目录
│ ├──musicList/src/main/ets // 音乐列表页面
│ │ ├──components
│ │ │ ├──AlbumComponent.ets // 封面内容组件
│ │ │ ├──AlbumCover.ets // 专辑封面组件
│ │ │ ├──ControlAreaComponent.ets // 播放控制自定义组件
│ │ │ ├──Header.ets // 顶部标题栏组件
│ │ │ ├──ListContent.ets // 列表内容组件
│ │ │ ├──LyricsComponent.ets // 歌词区域组件
│ │ │ ├──MusicControlComponent.ets // 歌曲区域组件
│ │ │ ├──MusicInfoComponent.ets // 音乐信息组件
│ │ │ ├──Player.ets // 播放控制区组件
│ │ │ ├──PlayList.ets // 播放列表组件
│ │ │ └──TopAreaComponent.ets // 歌词顶部区域组件
│ │ ├──constants
│ │ │ ├──ContentConstants.ets // 音乐列表常量类
│ │ │ ├──HeaderConstants.ets // 列表头部常量类
│ │ │ └──PlayerConstants.ets // 列表播控区常量类
│ │ ├──lyric
│ │ │ ├──LrcEntry..ets // 歌词信息实体类
│ │ │ ├──LrcUtils.ets // 歌词解析工具类
│ │ │ ├──LrcView.ets // 歌词详情自定义组件
│ │ │ └──LyricConst.ets // 歌词常量类
│ │ ├──view
│ │ │ └──MusicListPage.ets // 音乐列表主页面
│ │ ├──viewmodel
│ │ │ ├──SongDataSource.ets // 歌曲数据操作类
│ │ │ └──SongListData.ets // 歌曲列表数据类
│ ├──musicList/src/main/resources // 资源文件目录
└──products // 产品定制层
├──phone/src/main/ets // 支持手机、平板
│ ├──common
│ │ └──constants
│ │ └──HomeConstants.ets // 音乐常量类
│ ├──entryability
│ │ └──EntryAbility.ets // 程序入口类
│ ├──pages
│ │ └──Index.ets // 主界面
│ ├──phonebackupextability
│ │ └──PhoneBackupExtAbility.ets // 备份恢复类
│ └──viewmodel
│ ├──IndexItem.ets // 音乐信息实体类
│ └──IndexViewModel.ets // 音乐主页数据类
└──phone/src/main/resources // 资源文件目录
4 界面解读
4.1 音乐列表页
在音乐列表页中,音乐列表页由标题栏、专辑封面、歌曲列表和播放控制区四部分组成。
第①部分 标题栏在不同设备的标题栏始终只显示“返回按钮”、“歌单”以及“更多按钮”,中间空白区域通过Blank组件填充,可实现自适应拉伸能力。
1. @Component
2. export struct Header {
3. @StorageProp('currentBreakpoint') currentBreakpoint: string = BreakpointConstants.BREAKPOINT_SM;
4. @StorageLink('pageIndexInfos') pageIndexInfos: NavPathStack = new NavPathStack();
5.
6.
7. build() {
8. Row() {
9. Image($r('app.media.ic_back'))
10. .width($r('app.float.icon_width'))
11. .height($r('app.float.icon_height'))
12. .margin({ left: $r('app.float.icon_margin') })
13. .onClick(() => {
14. this.pageIndexInfos.pop();
15. })
16. Text($r('app.string.play_list'))
17. .fontSize(new BreakpointType({
18. sm: $r('app.float.header_font_sm'),
19. md: $r('app.float.header_font_md'),
20. lg: $r('app.float.header_font_lg')
21. }).getValue(this.currentBreakpoint))
22. .fontWeight(HeaderConstants.TITLE_FONT_WEIGHT)
23. .fontColor($r('app.color.title_color'))
24. .opacity($r('app.float.title_opacity'))
25. .letterSpacing(HeaderConstants.LETTER_SPACING)
26. .padding({ left: $r('app.float.title_padding_left') })
27.
28.
29. Blank()
30.
31.
32. Image($r('app.media.ic_more'))
33. .width($r('app.float.icon_width'))
34. .height($r('app.float.icon_height'))
35. .margin({ right: $r('app.float.icon_margin') })
36. .bindMenu(this.getMenu())
37. }
38. .width(StyleConstants.FULL_WIDTH)
39. .height($r('app.float.title_bar_height'))
40. .zIndex(HeaderConstants.Z_INDEX)
41. }
42. ...
43. }
第②部分 专辑封面由图片、歌单介绍及常用操作三部分组成,这三部分的布局可以用栅格实现。
• 在sm断点下,图片占4个栅格,歌单介绍占8个栅格,常用操作占12个栅格。
• 在md断点下,图片、歌单介绍和常用操作分别占12个栅格。
• 在lg断点下,图片、歌单介绍和常用操作分别占12个栅格。
// features/musicList/src/main/ets/components/AlbumComponent.ets
@Component
export struct AlbumComponent {
...
build() {
Column() {
GridRow() {
GridCol({
span: { sm: GridConstants.SPAN_FOUR, md: GridConstants.SPAN_TWELVE, lg: GridConstants.SPAN_TWELVE }
}) {
this.CoverImage()
}
GridCol({
span: { sm: GridConstants.SPAN_EIGHT, md: GridConstants.SPAN_TWELVE, lg: GridConstants.SPAN_TWELVE }
}) {
this.CoverIntroduction()
}
GridCol({
span: { sm: GridConstants.SPAN_TWELVE, md: GridConstants.SPAN_TWELVE, lg: GridConstants.SPAN_TWELVE }
}) {
this.CoverOptions()
}
.padding({
top: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ? $r('app.float.option_margin') : 0,
bottom: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ? $r('app.float.option_margin') : 0
})
}
.padding({
top: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.cover_padding_top_sm') : $r('app.float.cover_padding_top_other'),
left: new BreakpointType({
sm: $r('app.float.album_padding_sm'),
md: $r('app.float.album_padding_md'),
lg: $r('app.float.album_padding_lg')
}).getValue(this.currentBreakpoint),
right: new BreakpointType({
sm: $r('app.float.album_padding_sm'),
md: $r('app.float.album_padding_md'),
lg: $r('app.float.album_padding_lg')
}).getValue(this.currentBreakpoint)
})
}
.margin({
left: new BreakpointType({
sm: $r('app.float.cover_margin_sm'),
md: $r('app.float.cover_margin_md'),
lg: $r('app.float.cover_margin_lg')
}).getValue(this.currentBreakpoint),
right: new BreakpointType({
sm: $r('app.float.cover_margin_sm'),
md: $r('app.float.cover_margin_md'),
lg: $r('app.float.cover_margin_lg')
}).getValue(this.currentBreakpoint)
})
}
第③部分 对于不同屏幕尺寸的设备,歌曲列表的样式基本一致,但sm和md断点下的歌曲列表是单列显示,lg断点下的歌曲列表是双列显示。可以通过List组件的lanes属性实现这一效果。
// features/musicList/src/main/ets/components/PlayList.ets
@Component
export struct PlayList {
...
build() {
Column() {
this.PlayAll()
List() {
LazyForEach(new SongDataSource(this.songList), (item: SongItem, index: number) => {
ListItem() {
Column() {
this.SongItem(item, index)
}
.padding({
left: $r('app.float.list_item_padding'),
right: $r('app.float.list_item_padding')
})
}
}, (item: SongItem, index?: number) => JSON.stringify(item) + index)
}
.width(StyleConstants.FULL_WIDTH)
.backgroundColor(Color.White)
.margin({ top: $r('app.float.list_area_margin_top') })
.lanes(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ?
ContentConstants.COL_TWO : ContentConstants.COL_ONE)
.layoutWeight(1)
.divider({
color: $r('app.color.list_divider'),
strokeWidth: $r('app.float.stroke_width'),
startMargin: $r('app.float.list_item_padding'),
endMargin: $r('app.float.list_item_padding')
})
}
.padding({
top: this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM ? 0 : $r('app.float.list_area_padding_top'),
bottom: $r('app.float.list_area_padding_bottom')
})
}
}
第④部分对于不同屏幕尺寸的设备,播放控制区显示的内容有差异,sm断点仅显示播放按钮,md断点显示上一首、播放和下一首按钮,lg断点显示收藏、上一首、播放、下一首、列表按钮,可以分别设置按钮的displayPriority属性,通过自适应隐藏能力实现。同时,歌曲信息与播放控制按钮之间通过Blank组件自动填充,实现自适应拉伸。
// features/musicList/src/main/ets/components/Player.ets
@Component
export struct Player {
...
build() {
Row() {
...
Row() {
Image($r('app.media.ic_previous'))
...
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_TWO)
Image(this.isPlay ? $r('app.media.ic_play') : $r('app.media.ic_pause'))
...
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_THREE)
Image($r('app.media.ic_next'))
...
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_TWO)
Image($r('app.media.ic_music_list'))
...
.displayPriority(PlayerConstants.DISPLAY_PRIORITY_ONE)
}
.width(new BreakpointType({
sm: $r('app.float.play_width_sm'),
md: $r('app.float.play_width_sm'),
lg: $r('app.float.play_width_lg')
}).getValue(this.currentBreakpoint))
.justifyContent(FlexAlign.End)
}
...
.position({
x: 0,
y: StyleConstants.FULL_HEIGHT
})
.translate({
x: 0,
y: StyleConstants.TRANSLATE_PLAYER_Y
})
}
}
4.2 音乐播放页
在音乐播放页中,音乐播放页面由歌曲页面和歌词页面两部分组成。
在sm断点下通过Swiper组件切换播控区域以及歌词区域,在md以及lg断点下播控区域以及歌词区域左右两列展示,在md断点下GridRow设置总栅格数为8,播控区域以及歌词区域分别占4个栅格,在lg断点下GridRow设置总栅格数为12,播控区域占4个栅格,GridCol设置offset为1,歌词区域占6个栅格,设置offset为1。
// features/musicList/src/main/ets/components/MusicControlComponent.ets
@Component
struct MusicControlComponent {
...
build() {
...
Row() {
if (this.isFoldFull) {
Column() {
...
GridRow({
columns: { md: BreakpointConstants.COLUMN_MD },
gutter: BreakpointConstants.GUTTER_MUSIC_X
}) {
GridCol({
span: { md: BreakpointConstants.SPAN_SM }
}) {
MusicInfoComponent()
}
.margin({
left: $r('app.float.margin_small'),
right: $r('app.float.margin_small')
})
GridCol({
span: { md: BreakpointConstants.SPAN_SM }
}) {
LyricsComponent({ isShowControl: this.isShowControlLg, isTablet: this.isTabletFalse })
}
.padding({
left: $r('app.float.twenty_four')
})
}
...
}
...
} else if (this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG) {
Column() {
...
GridRow({
columns: { md: BreakpointConstants.COLUMN_MD, lg: BreakpointConstants.COLUMN_LG },
gutter: BreakpointConstants.GUTTER_MUSIC_X
}) {
GridCol({
span: { md: BreakpointConstants.SPAN_SM, lg: BreakpointConstants.SPAN_SM },
offset: { lg: BreakpointConstants.OFFSET_MD }
}) {
Column() {
Image(this.songList[this.selectIndex].label)
.width(StyleConstants.FULL_WIDTH)
.aspectRatio(1)
.borderRadius($r('app.float.cover_radius'))
ControlAreaComponent()
}
.height(StyleConstants.FULL_HEIGHT)
.justifyContent(FlexAlign.SpaceBetween)
.margin({
bottom: $r('app.float.common_margin')
})
}
GridCol({
span: { md: BreakpointConstants.SPAN_SM, lg: BreakpointConstants.SPAN_MD },
offset: { lg: BreakpointConstants.OFFSET_MD }
}) {
LyricsComponent({ isShowControl: this.isShowControlLg, isTablet: this.isTablet })
}
}
...
}
} else {
Stack({ alignContent: Alignment.TopStart }) {
Swiper() {
MusicInfoComponent()
.margin({
top: $r('app.float.music_component_top'),
bottom: $r('app.float.music_component_bottom')
})
.padding({
left: $r('app.float.common_padding'),
right: $r('app.float.common_padding')
})
LyricsComponent({ isShowControl: this.isShowControl, isTablet: this.isTabletFalse })
.margin({
top: $r('app.float.margin_lyric')
})
.padding({
left: $r('app.float.common_padding'),
right: $r('app.float.common_padding')
})
}
...
}
.height(StyleConstants.FULL_HEIGHT)
}
}
.padding({
bottom: this.bottomArea,
top: this.topArea
})
...
}
...
}
4.3 音乐评论页
在音乐评论页中,音乐评论页由标题、歌曲信息、精彩评论和最新评论四部分组成。
标题栏在不同的设备上面自适应显示“返回按钮”和“评论”。歌曲信息在不同设备的显示内容一样,中间空白区域通过Blank组件填充,可实现自适应拉伸能力。精彩评论列表和最新评论列表对于不同屏幕尺寸的设备,评论列表的样式基本一致,但sm和md断点下的精彩评论列表和最新评论列表是单列显示,lg断点下的精彩评论列表和最新评论列表是双列显示,可以通过List组件的lanes属性实现这一效果。
// features/musicComment/src/main/ets/view/MusicCommentPage.ets
@Entry
@Component
struct MusicCommentPage {
...
@Builder ShowTitle(title: ResourceStr) {
Row() {
Text(title)
...
}
...
}
build() {
GridRow({
breakpoints: {
value: BreakpointConstants.BREAKPOINT_VALUE,
reference: BreakpointsReference.WindowSize
},
columns: {
sm: BreakpointConstants.COLUMN_SM,
md: BreakpointConstants.COLUMN_MD,
lg: BreakpointConstants.COLUMN_LG
},
gutter: { x: BreakpointConstants.GUTTER_X }
}) {
GridCol({
span: {
sm: BreakpointConstants.COLUMN_SM,
md: BreakpointConstants.COLUMN_MD,
lg: BreakpointConstants.COLUMN_LG
}
}) {
Column() {
HeadComponent()
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right')
})
MusicInfoComponent()
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right')
})
this.ShowTitle($r('app.string.wonderful_comment'))
List() {
...
}
.lanes(this.currentBp === BreakpointConstants.BREAKPOINT_LG ? 2 : 1)
.scrollBar(BarState.Off)
.divider({
color: $r('app.color.list_divider'),
strokeWidth: $r('app.float.stroke_width'),
startMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.start_margin') : $r('app.float.start_margin_lg'),
endMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.divider_margin_left')
})
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left_list'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right_list')
})
this.ShowTitle($r('app.string.new_comment'))
List() {
...
}
.layoutWeight(1)
.lanes(this.currentBp === BreakpointConstants.BREAKPOINT_LG ? 2 : 1)
.scrollBar(BarState.Off)
.margin({
left: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_left_sm') : $r('app.float.margin_left_list'),
right: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.margin_right_sm') : $r('app.float.margin_right_list')
})
.divider({
color: $r('app.color.list_divider'),
strokeWidth: $r('app.float.stroke_width'),
startMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
$r('app.float.start_margin') : $r('app.float.start_margin_lg'),
endMargin: this.currentBp === BreakpointConstants.BREAKPOINT_SM ?
0 : $r('app.float.divider_margin_left')
})
}
.height(StyleConstants.FULL_HEIGHT)
}
}
...
}
}
5 歌单音乐
歌单中的音乐数据来自SongItem数组,id是歌曲的排序序号,title是歌曲名称,singer是演唱歌手,label是歌曲专辑背景图,src既引入的歌曲文件,在歌单中添加或删除歌曲在SongItem数组添加一项或减去一项,添加歌曲时要配置好歌曲的各个属性,既前面描述的
信息
features\musicList\src\main\ets\viewmodel\SongListData.etsconst songList: SongItem[] = [
{ id: 1, title: 'Dream It Possible', singer: 'Delacey', mark: $r('app.media.ic_vip'),
label: $r('app.media.ic_dream'), src: 'Delacey - Dream It Possible.flac', index:0,
lyric: 'lrcfiles/DreamItPossible.lrc' },
{ id: 2, title: '不知道', singer: '张三-你好我好都好', mark: $r('app.media.ic_sq'),
label: $r('app.media.ic_avatar2'), src: 'world.wav', index:1, lyric: '' },
{ id: 3, title: '还是歌名', singer: '不知道你是谁', mark: $r('app.media.ic_vip'),
label: $r('app.media.ic_avatar16'), src: 'power.wav', index:2, lyric: '' },
…
{ id: 35, title: '晚风', singer: 'kt', mark: $r('app.media.ic_sq'),
label: $r('app.media.ic_avatar11'), src: 'Copy.mp3', index:34, lyric: '' },
{ id: 36, title: '心做', singer: 'kt', mark: $r('app.media.ic_sq'),
label: $r('app.media.ic_avatar11'), src: 'xinzuo.mp3', index:35, lyric: '' },
{ id: 37, title: 'why would I ever', singer: 'kt', mark: $r('app.media.ic_sq'),
label: $r('app.media.ic_avatar11'), src: 'why would I ever.mp3', index:36, lyric: '' }
]
const optionList : OptionItem[] = [
{ image: $r('app.media.ic_collect'), text: $r('app.string.collect') },
{ image: $r('app.media.ic_download'), text: $r('app.string.download') },
{ image: $r('app.media.ic_comments'), text: $r('app.string.comment'), action: (pageIndexInfos: NavPathStack) => {
pageIndexInfos.pushPathByName(RouterUrlConstants.MUSIC_COMMENT, null);
}},
{ image: $r('app.media.ic_share'), text: $r('app.string.share') }
]
class OptionItem {
image: Resource = $r('app.media.ic_collect');
text?: Resource;
action?: (pageIndexInfos: NavPathStack) => void;
}
export { optionList, OptionItem, songList }
借鉴文章链接:https://developer.huawei.com/consumer/cn/codelabsPortal/carddetails/tutorials_NEXT-MusicHome