本部分我们将再次把重点转移到微信小程序前端, 包含 2 个内容: “音频播放器”的实现, “发布日记”前端功能实现.
另外, 还将顺道介绍一下微信小程序中自定义组件的使用.
23. 实现”音频播放器”
本节我们来把小程序首页上那个模拟的音频播放器搞定!
查阅微信小程序文档可知音频播放共有 3 种方式: 普通音频, 背景音频, 实时音频.
第 1 种, 普通音频(InnerAudioContext) 试了一下, 有 Bug 影响我们的功能实现. 2018 年就已有人向官方反馈, 至今未修复, 差评!
第 3 种, 实时音视频我们这里用不到. 本教程使用背景音频, 使用到的 API 都在 官方文档 里有说明, 遇到不清楚的可查阅.
本节主要目的是带着大家体验搭积木的快乐 ~ 先看看我们做的东西…
就这么个东西还配个图… 主要是统一那些东东的称呼, 免得后面不知道我在说什么…
需要实现的功能有 3 个: (1) 播放/暂停 (2) 实时显示当前播放时长(当前播放到哪里) (3) 拖动滑块可调节播放进度
需要注意的问题是, 日记列表中可能有多条日记带有音频, 所以多个音频的各项控制功能应能”联动”.
好了, 不说了, 我们动手吧 ~
别乱, 按套路来…
(1) 对照上图界面, 建立对应的数据模型
(2) 数据绑定
(3) 实现对用户操作的响应(事件绑定与处理)
23.1 数据模型
小朋友们掰着手指头数一数, 图中有哪些”活”的东西?
(1) 音频是否正在播放 ( isAudioPlaying ), 对应界面上是显示播放图标还是暂停图标
(2) 现在播放到哪里了( audioElapsed, 秒), 对应当前播放时长和滑块位置
(3) 音频总时长 ( audioDuration, 秒)
(4) 音频文件路径 ( audioPath )
其中, 3, 4 两项此前的数据模型中已经有了, 只需要在原来的基础上补充 1, 2 两项即可.
需要注意的是, 日记列表中可能有多条日记带音频, 所以, 我们还得记录一下当前正在播放的是哪一条? ( curAudioDiaryIndex )
curAudioDiaryIndex 是页面全局数据, 所以, 应记录在页面 ( page ) 的数据模型中, 而其余的数据项是每一条日记相关的数据, 所以,应记录在各条日记的数据模型里.
也就是说, 我们要这样改一下…
1 2 3 4 5 6
| Page({ data: { curAudioDiaryIndex : -1 }, ..... })
|
而一条日记的数据大概应是这样子的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "id":"d3", "userId":"u3", "title":"白雪公主", "publishTime":"2020-04-24T13:34:30.000Z", "cover":"https://bailey.pinruikm.com/images/miniprogram/dr3.jpg", "content":"白雪公主受到继母皇后,逃到森林里,遇到七个小矮人的故事。", "audioPath":"https://bailey.pinruikm.com/images/miniprogram/audio3.mp3", "audioDuration":73, "readCnt":12, "praiseCnt":7, "author":"小宝贝", "isAudioPlaying": false, "audioElapsed": 0 }
|
上面这段东西中, 如果把第 13, 14 行剔除, 那就是直接从服务器端返回的单条日记数据的样子.
那需不需要在什么地方把缺的这两项加上呢? 看你喜欢了… 其实不加也行…
23.2 数据绑定
这一步做的是数据模型到视图层的映射…
打开 /pages/index/index.wxml
找到下面这段代码 (音频文件相关的那一段)
1 2 3 4 5 6 7 8 9 10 11 12
| .... <view wx:if="{{item.audioPath}}" class="audio flex align-center m-t-xs"> <view class="icon-play ic-xxl"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>0:00</text> <slider class="flex-sub" activeColor="#666" block-size="12"/> <text>{{filter.sec2Min(item.audioDuration)}}</text> </view> </view> </view> ....
|
找找看, 23.1 节的提到的那些”活”的数据, 应该绑定在哪些地方?
Code 23-1: /pages/index/index.wxml
1 2 3 4 5 6 7 8 9 10 11 12
| .... <view wx:if="{{item.audioPath}}" class="audio flex align-center m-t-xs"> <view class="{{item.isAudioPlaying?'icon-pause':'icon-play'}} ic-xxl"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>{{filter.sec2Min(item.audioElapsed)}}</text> <slider class="flex-sub" activeColor="#666" block-size="12" min="0" max="{{item.audioDuration}}" disabled="{{index !== curAudioDiaryIndex}}" value="{{item.audioElapsed}}"/> <text>{{filter.sec2Min(item.audioDuration)}}</text> </view> </view> </view> ....
|
改了 3 个地方, 第 3, 6 行…. 就不说了, 很简单, 自己猜…
第 7 行, 这里我们使用微信提供的 slider
组件 ( 参考文档 )
其中, min
和 max
分别是滑动可滑动范围的最小值和最大值, 默认是 0 - 100, 这里我们直接使用音频总时长(audioDuration) 作为最大值(max).
value
: 当前滑块位置, 直接绑定当前已播放的时长(audioElapsed). 现在, 你明白为什么要用音频总时长作为最大值了吧?
disabled
: 当前滑块组件是否可用, 即是否可以拖动. 如果正在播放的不是当前日记的音频 ( index !== curAudioDiaryIndex ), 则不可用 ( 在 wx:for 中 index 是循环变量 )
保存…. 似乎没什么变化, 呵呵~ 因为一切都还是初始的样子…
23.3 播放一首歌
前面说过, 我们要使用微信提供的”背景音频”播放组件来播放音频. [官方参考文档]
首先, 使用 wx.getBackgroundAudioManager()
获得背景音频管理器(BackgroundAudioManager), 后续对音频播放状态的控制和对状态变化的监听都要靠它来完成.
下面是我们将使用到的背景音频管理器 API, 先简单介绍一下:
- play() 开始播放
- pause() 暂停播放
- onPlay : 开始播放事件的回调函数
- onPause: 暂停播放事件的回调函数
- onStop: 停止播放事件的回调函数
- onEnded: 播放结束事件(音频放完了)的回调函数
- onTimeUpdate: 播放进度变化事件的回调函数
呵呵~ 好多事件回调函数… 为什么要使用这些回调函数? 因为… 我们要监听回调函数感知音频的播放状态呀 ~
来动手改代码吧~
Code 23-2: /pages/index/index.wxml
1 2 3 4 5 6 7 8 9 10 11 12
| .... <view wx:if="{{item.audioPath}}" class="audio flex align-center m-t-xs"> <view class="{{item.isAudioPlaying?'icon-pause':'icon-play'}} ic-xxl" catchtap="audioPlayOrPause" data-index="{{index}}"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>{{filter.sec2Min(item.audioElapsed)}}</text> <slider class="flex-sub" activeColor="#666" block-size="12" min="0" max="{{item.audioDuration}}" disabled="{{index !== curAudioDiaryIndex}}" value="{{item.audioElapsed}}" /> <text>{{filter.sec2Min(item.audioDuration)}}</text> </view> </view> </view> ....
|
在 Code 23-1 的基础中增加了第 3 行对播放/暂停按钮 tap 事件的监听 (参看第三部分第 13 节)
注意两个地方:
- 使用
catchtap
监听播放/暂停按钮 tap 事件, 若触发则回调 audioPlayOrPause
( 见 Code 24-3 )
data-index="{{index}}"
将当前日记项的序号作为 tap 事件参数 (index) 传递到回调函数中
使用 catchtap , 而不使用 bindtap, 为什么? 参看第三部分第 13 节
Code 23-3: /pages/index/index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| const api = require('../../utils/api');
const audioPlayer = wx.getBackgroundAudioManager();
Page({ data: { curAudioDiaryIndex : -1 }, onLoad: async function () { const _this = this; wx.showLoading({ title: '数据加载中...', }); wx.request({ url: 'http://localhost:8000', success: function(resp) { const dataFromServer = resp.data; _this.setData({ userInfo: dataFromServer.userInfo, diaries: dataFromServer.diaries }); wx.hideLoading(); }, fail: function(resp) { wx.showToast({ title: '数据请求失败', icon: 'none' }); } }); audioPlayer.onPlay(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: true}); }); audioPlayer.onPause(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false}); }); audioPlayer.onStop(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false, [`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: 0}); }); audioPlayer.onEnded(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false, [`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: 0}); }); audioPlayer.onTimeUpdate(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: audioPlayer.currentTime}); }); }, audioPlayOrPause: function (e) { const index = e.currentTarget.dataset.index; const diary = this.data.diaries[index]; this.setData({ curAudioDiaryIndex: index });
if (!diary.isAudioPlaying) { audioPlayer.src = diary.audioPath; audioPlayer.title = diary.title; audioPlayer.singer = diary.author; audioPlayer.coverImgUrl = diary.cover; audioPlayer.play(); } else { audioPlayer.pause(); } } })
|
突然多出好多代码, 有点吓人 ~ 呵呵, 其实主要是 3 块内容:
第 4 行: 获得背景音频管理器(BackgroundAudioManager)
第 34 ~ 54 行: 在页面的加载事件回调函数(onLoad)中注册对 BackgroundAudioManager 各种事件的监听. ( 想一想, 为什么要写在 onLoad 里? )
this.data.curAudioDiaryIndex : 当前正在播放的音频日记项的序号 ( 第 64 行存入 )
第 37, 41, 45, 49, 53 行均是根据音频播放的状态来更新当前日记项的数据模型值.
拿第 37 行举例, 如果把代码写成这个样子: this.setData({ 'diaries[0].isAudioPlaying': true});
能看懂吗? 不懂的话看第三部分第 11 节.
上面这个代码的意思即是设置第 0 条日记的 isAudioPlaying 属性值为 true. 还记得吗,第 24.1节说过, isAudioPlaying 是日记的音频播放的状态(true表示正在播放).
所以, 第 37 行的意思即是当音频开始播放事件触发时, 记录下当前日记音频”正在播放”.
diaries[${this.data.curAudioDiaryIndex}]
即是当前正在播放的日记项.
上面不是说了嘛, this.data.curAudioDiaryIndex 是当前正在播放的音频日记项的序号. 这里使用了模板字符串, 假设 this.data.curAudioDiaryIndex 的值是 0, 那么第 37 行的代码的结果就是:this.setData({ [diaries[0].isAudioPlaying]: false});
稍微需要注意的是, diaries[0].isAudioPlaying 的两端不是单引号( ‘ ‘ ), 而是中括号( [ ] ), 其实在这里使用单引号或中括号的含意是一样的, 只是这里使用了模板字符串, 只能用中括号, 用单引号有语法错误…
第 53 行 audioPlayer.currentTime
是当前音频的播放位置(单位: 秒)
第 56 ~ 77 行: 页面中播放/暂停按钮的 tap 事件回调函数. 每一行都写了注释, 就不罗嗦了…
OK ~ 保存刷新, 是不是有点小兴奋 ~
总感觉有些不放心… 给个练习题做一下吧.
研究一下这些个 console.log() 的输出结果. 然后自己总结一下, 访问一个对象的属性有些什么方法.
1 2 3 4 5 6 7 8 9
| const s = { name: 'zhangsan', firstname: 'zhang'}; const prop = 'name';
console.log( s.name ); console.log( s['name'] ); console.log( s.prop ); console.log( s[prop] ); console.log( s.firstname ); console.log( s[`first${prop}`] );
|
23.4 搞定进度调节滑块
套路: 监听滑块拖动事件 → 控制播放器状态…
下列代码在 Code 23-2 的基础上修改了第 7 行. 监听<slide>
组件的 change
事件, 滑块拖动时将回调 onChangeAudioPos
函数
Code 23-4 /pages/index/index.wxml
1 2 3 4 5 6 7 8 9 10 11 12
| .... <view wx:if="{{item.audioPath}}" class="audio flex align-center m-t-xs"> <view class="{{item.isAudioPlaying?'icon-pause':'icon-play'}} ic-xxl" catchtap="audioPlayOrPause" data-index="{{index}}"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>{{filter.sec2Min(item.audioElapsed)}}</text> <slider class="flex-sub" activeColor="#666" block-size="12" min="0" max="{{item.audioDuration}}" disabled="{{index !== curAudioDiaryIndex}}" value="{{item.audioElapsed}}" bindchange="onChangeAudioPos"/> <text>{{filter.sec2Min(item.audioDuration)}}</text> </view> </view> </view> ....
|
在 index.js 中添加如下函数:
Code 23-5 /pages/index/index.js
1 2 3 4 5 6
| onChangeAudioPos: function(e) { const pos = e.detail.value; audioPlayer.seek(pos); if (audioPlayer.paused) audioPlayer.play(); }
|
保存刷新, 嘿嘿, 是不是可以拖动滑块调节播放位置了 ~
但是, 有个小 Bug … 慢慢拖动的时候滑块会跳来跳去的… 想想为什么?
呵呵, 看 Code 23-4 第 7 行, 滑块的位置绑定了当前音频的播放进度 ( value="{{item.audioElapsed}}"
)
在音频播放过程中, 每一次播放进度变化(onTimeUpdate事件回调)都会导致audioElapsed
变化 (参看 Code 23-3, 第 52 ~ 54 行), 所以, 你才能看到音频播放时滑块会自己移动.
而现在, 你又用手拖着那个滑块跑…. 于是就打架了… 它在播放进度和你的手之间徘徊, 呵呵~
怎么办?
可以考虑加一个状态标志, 如果你手在拖, 那么就听手的, 否则按播放进度移动… 是不是就搞定了.
监听”正在拖”事件… 下面的代码增加了第 7 行最后的bindchanging="onChangingAudioPos"
Code 23-6: /pages/index/index.wxml
1 2 3 4 5 6 7 8 9 10 11 12
| .... <view wx:if="{{item.audioPath}}" class="audio flex align-center m-t-xs"> <view class="{{item.isAudioPlaying?'icon-pause':'icon-play'}} ic-xxl" catchtap="audioPlayOrPause" data-index="{{index}}"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>{{filter.sec2Min(item.audioElapsed)}}</text> <slider class="flex-sub" activeColor="#666" block-size="12" min="0" max="{{item.audioDuration}}" disabled="{{index !== curAudioDiaryIndex}}" value="{{item.audioElapsed}}" bindchange="onChangeAudioPos" bindchanging="onChangingAudioPos"/> <text>{{filter.sec2Min(item.audioDuration)}}</text> </view> </view> </view> ....
|
Code 23-7: /pages/index/index.js (局部代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| ....
audioPlayer.onTimeUpdate(() => { if (!this.data.isChangingAudioPos) { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: audioPlayer.currentTime}); } });
....
onChangingAudioPos: function(e) { const curAudioDiaryIndex = this.data.curAudioDiaryIndex; this.setData({ isChangingAudioPos: true, [`diaries[${curAudioDiaryIndex}].audioElapsed`]: e.detail.value }); },
onChangeAudioPos: function(e) { const pos = e.detail.value; audioPlayer.seek(pos); if (audioPlayer.paused) audioPlayer.play(); this.setData({ isChangingAudioPos: false}); }
|
改了 3 个地方, 在自己的代码中找一找, 别改错地方了哦 ~
- 第 13 ~ 19 行: 监听”正在拖动”事件, 用
this.data.isChangingAudioPos
标志记录当前正在拖动. 顺带更新了一下播放进度数据(当前播放时长), 以使当前播放时长与滑块位置同步.
- 第 26 行: 拖动结束, 更新 isChangingAudioPos 标志的状态为 false
- 第 5 ~ 7 行: 加了一个 if 条件, 即如果没有”正在拖动”, 那么以正常播放进度设置滑块位置.
微信小程序 slider 组件拖动滑动会触发两个事件: changing: 拖动过程中, change: 完成一次拖动后, 参看微信小程序文档: slider
很多组件 (包括其它技术) 都有类似事件. 比如, 文本输入框在输入过程中会连续触发 changing/change, 输入完按回车或失去输入焦点触发 change/changed.
这里比较乱, 按理说变化中触发 changing, 结束触发 changed 比较符合语义, 但实际上各种技术中都有自己的想法. 遇到的时候注意看文档, 或者直接实验一下.
OK ~ 现在再拖一下试试, 是不是流畅多了, 再也不会蹦来蹦去了 ~
23.5 多个音频协作
大麻烦来了~ 如果日记列表中有多个音频, 如果用户在不同的音频之间切换, 小程序会呈现半身不遂的状态… 试试呗 ~
如果你一直跟着教程做, 用的是教程提供的测试数据, 应该有 4 条日记, 其中最后两条都有会唱歌.
白雪公主: 《知道不知道》, 天下无贼 BGM
青鸟:《渔舟唱晚》, 天气预报, 呵呵 ~
只播放一个好使得很, 两个互相切换就不听使唤了, 这又是什么鬼…
首先, 要明确一点, 不可能同时两首歌一起唱… 条件不允许…
实事上, 微信小程序的 BackgroundAudioManager 一个时间只能放一首歌, 如果你切歌, 原来那首就会停止, 立马开始唱新歌…
也就是说, 我们多个音频的播放其实是在共享一个播放器, 但我们的代码里没有妥善处理这个”共享资源”, 于是就又打架了…
先放代码吧, 这回只需要动 index.js, 下面是 index.js 的完整代码, 你可以全部复制替换.
Code 23-8: /pages/index/index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| const api = require('../../utils/api');
const audioPlayer = wx.getBackgroundAudioManager();
Page({ data: { curAudioDiaryIndex : -1 }, onLoad: async function () { const _this = this; wx.showLoading({ title: '数据加载中...', }); wx.request({ url: 'http://localhost:8000', success: function(resp) { const dataFromServer = resp.data; _this.setData({ userInfo: dataFromServer.userInfo, diaries: dataFromServer.diaries }); wx.hideLoading(); }, fail: function(resp) { wx.showToast({ title: '数据请求失败', icon: 'none' }); } }); audioPlayer.onPlay(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: true}); }); audioPlayer.onPause(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false}); }); audioPlayer.onStop(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false, [`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: 0}); }); audioPlayer.onEnded(() => { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].isAudioPlaying`]: false, [`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: 0}); }); audioPlayer.onTimeUpdate(() => { if (!this.data.isChangingAudioPos) { this.setData({[`diaries[${this.data.curAudioDiaryIndex}].audioElapsed`]: audioPlayer.currentTime}); } }); }, audioPlayOrPause: function (e) { const index = e.currentTarget.dataset.index; const diary = this.data.diaries[index];
if (diary.isAudioPlaying) return audioPlayer.pause();
const curAudioDiaryIndex = this.data.curAudioDiaryIndex; if (curAudioDiaryIndex >= 0 && curAudioDiaryIndex !== index) { audioPlayer.pause(); this.setData({[`diaries[${curAudioDiaryIndex}].isAudioPlaying`]: false}); }
if (!audioPlayer.src || audioPlayer.src !== diary.audioPath) { audioPlayer.src = diary.audioPath; audioPlayer.title = diary.title; audioPlayer.singer = diary.author; audioPlayer.coverImgUrl = diary.cover;
if (diary.audioElapsed > 0) audioPlayer.seek(diary.audioElapsed);
this.setData({ curAudioDiaryIndex: index }); }
audioPlayer.play(); }, onChangingAudioPos: function(e) { const curAudioDiaryIndex = this.data.curAudioDiaryIndex; this.setData({ isChangingAudioPos: true, [`diaries[${curAudioDiaryIndex}].audioElapsed`]: e.detail.value }); }, onChangeAudioPos: function(e) { const pos = e.detail.value; audioPlayer.seek(pos); if (audioPlayer.paused) audioPlayer.play(); this.setData({ isChangingAudioPos: false}); } })
|
以上的代码, 主要改变的地方在 audioPlayOrPause
函数里 ( 第 60 ~ 91 行 ), 都写了注释, 慢慢看…
/pages/index/index.wxml
的完整代码也放一下吧, 天晓得你会跳进那个坑里呢… 如果你的程序已可正常工作, 可忽略.
Code 23-9: /pages/index/index.wxml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| <wxs module="filter" src="../../utils/filter.wxs"></wxs>
<view class="navi shadow"> <view class="title"> <view class="nickName"> <open-data type="userNickName" lang="zh_CN"></open-data> </view> </view> <view class="flex align-center p-t"> <view class="m-r"> <open-data class="avatar" type="userAvatarUrl"></open-data> </view> <view class="flex space-around w-full"> <view class="text-center"> <view>{{userInfo.favCnt}}</view> <view class="text-xs">关注</view> </view> <view class="text-center"> <view>{{userInfo.fansCnt}}</view> <view class="text-xs">粉丝</view> </view> <view class="text-center"> <view>{{userInfo.diaryCnt}}</view> <view class="text-xs">日记</view> </view> </view> </view>
<view class="flex text-xs m-t"> <open-data type="userGender" lang="zh_CN"></open-data> <text class="m-lr-xs text-lighter"> | </text> <view> <open-data type="userProvince" lang="zh_CN"></open-data> <open-data type="userCity" lang="zh_CN"></open-data> </view> </view>
<view class="icon-record3 new-diary"> 打卡</view> </view>
<view> <view class="diary-item" wx:for="{{diaries}}" wx:key="id"> <view class="flex p-sm align-start m-t-xs bg-white b-b"> <view class="flex column align-center m-r"> <view class="text-xl text-bold">{{filter.formatDate(item.publishTime, 'D')}}</view> <view class="text-xs">{{filter.formatDate(item.publishTime, 'M')}}月</view> </view> <view class="flex-sub flex column"> <view class="flex justify w-full text-sm"> <view class="text-gray"> <text>{{item.readCnt}} 次浏览</text> · <text>{{item.praiseCnt}} 人点赞</text> </view> <view class="text-link">♪ {{item.author}}</view> </view> <view class="flex justify align-start w-full m-tb-xs"> <image class="book-cover" src="{{item.cover}}" mode="aspectFill"></image> <view class="flex-sub m-lr-sm"> <view class="text-md">{{item.title}}</view> <view class="text-gray text-sm m-t-xxs"> <text class="text-ellipsis line-2">{{item.content}}</text> </view> </view> </view> <view wx:if="{{item.audioPath}}" class="audio flex align-center m-t-xs"> <view class="{{item.isAudioPlaying?'icon-pause':'icon-play'}} ic-xxl" catchtap="audioPlayOrPause" data-index="{{index}}"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>{{filter.sec2Min(item.audioElapsed)}}</text> <slider class="flex-sub" activeColor="#666" block-size="12" min="0" max="{{item.audioDuration}}" disabled="{{index !== curAudioDiaryIndex}}" value="{{item.audioElapsed}}" bindchange="onChangeAudioPos" bindchanging="onChangingAudioPos" /> <text>{{filter.sec2Min(item.audioDuration)}}</text> </view> </view> </view> <view class="footer"> <view class="icon-heart"> 点赞</view> <view class="icon-fav"> 关注</view> <view class="icon-comment"> 评论</view> </view> </view> </view> </view> </view>
|
好了, 搞定! 是不是感觉自己还是有点牛气的 ~
稍微小结一下:
- 遵循 MVC 模型思想可以让你的思路变得清晰, 知道从何下手.
- 程序运行起来后, 数据将沿着一个方向循环流动: M → V → M → V → …, 你的程序要随时保证 M 和 V 之间的一致性
24. 实现”发布日记”前端功能
本节我们来实现”阅读打卡”小程序中一个重要功能: 发布日记 … 的前端功能, 服务器端的实现将在下一节介绍.
先打个预防针, 本节也许是整个序列教程中最难的一节…
深呼吸~ 做好准备就来吧~
先说说套路: (1) 界面设计 (2) 数据模型 (3) 数据绑定 (4) 事件绑定
24.1 界面设计
来吧~ 界面已经帮你设计好了~
在/pages
文件夹下再新建一个文件夹: diary
( /pages/diary )
右键单击 diary 文件夹图标, 右键菜单中选择”新建Page”, 名称输入”diary”, 回车…
你会看到 /pages/diary/
下多了4个文件: diary.js, diary.json, diary.wxml, diary.wxss, 同时 app.json 的 pages 节点下多了一个页面注册项: pages/diary/diary
简单来说, 开发平台帮你创建了一个新的页面 ( /pages/diary/diary ) 所需的 4 个文件, 同时将此页面添加到 app.json 的页面列表中.
打开 app.json 手动交换一下 /pages/index/index 和 /pages/diary/diary 的次序.
此操作把新建的”发布日记”页面设置为小程序首页, 以方便后面的操作.
以防万一, 截个图吧 ~ 如果不一致, 就自行修改…
接下来, 请自己试着完成上图所示界面的代码编写吧 ~ ( 莫偷懒, 学了半天, 总得自己练一下吧 )
OK ~ 假设你已经完成了”发布日记”页面的代码编写…… 我们继续…
以下是本教程使用的代码, 若你已自行完成编码(手动点赞!), 可暂时把你的代码备份, 然后用以下代码覆盖, 以便后续章节中能与我同步…
Code 24-1: /pages/diary/diary.wxml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| <view class="nav" style="padding-top:24px; line-height: 32px;"> <view class='nav-btns'> <view class="icon-left"></view> </view> <view class="text-center text-bold">发表日记</view> </view>
<view class="flex flex-column p-sm"> <textarea class="content-box" placeholder="这么优秀的你, 有什么想跟大家说的 ~" placeholder-class="text-gray"></textarea>
<view class="audio flex align-center"> <view class="{{item.isAudioPlaying?'icon-pause':'icon-play'}} ic-xxl" catchtap="audioPlayOrPause"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>0:00</text> <slider class="flex-sub" activeColor="#666" block-size="12"/> <text>4:16</text> </view> </view> <view class="delete-icon icon-close"></view> </view>
<view class="recorder flex b r-2x p-sm"> <view class="icon-close text-gray p-r-sm b-r"></view> <view class="flex justify p-l w-full"> <view class="flex flex-sub text-red p-r"> <view class="fading icon-record1"></view> <text class="flex-sub">00:00</text> <view class="icon-pause"></view> </view> <text class="text-blue b-l p-l-sm">完成</text> </view> </view>
<view class="flex start w-full"> <image class="media-item" src="https://bailey.pinruikm.com/images/miniprogram/dr2.jpg"></image> <view class="media-item empty"> <view class="icon-photo-add"></view> </view> <view class="media-item empty"> <view class="icon-record2"></view> </view> </view>
<view class="w-full m-t p"> <view class="flex b-b"> <view class="icon-comment text-gray m-r">添加标题</view> <input class="weui-input" maxlength="20"/> </view> </view>
<view class="w-full flex m-t-lg"> <button class="flex-sub bg-gradual-green">发 表</button> </view> </view>
|
Code 24-2: /pages/diary/diary.wxss
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| .nav { position: relative; } .nav .nav-btns { position: absolute; padding-left: 15rpx; }
.content-box { width: 100%; height: 6rem; }
.audio { position: relative; background: linear-gradient(-180deg, #BCC5CE 0%, rgb(140, 148, 155) 98%), radial-gradient(at top left, rgba(255,255,255,0.30) 0%, rgba(0,0,0,0.30) 100%); background-blend-mode: screen; padding: 0 20rpx; color: #fff; width: 100%; box-sizing: border-box; border-radius: 3px; margin: var(--spaceSm); } .delete-icon { position: absolute; right: 5rpx; top: 5rpx; width: 30rpx; line-height: 30rpx; text-align: center; background-color: var(--red); color: white; border-radius: 50%; } .delete-icon:before { margin-right: 0; font-size: 20rpx; font-weight: bold; }
.recorder { padding: 0 20rpx; color: var(--gray); width: 100%; box-sizing: border-box; border-radius: 3px; margin: var(--spaceSm); } .recorder .fading { animation-name: fading; animation-duration: 1s; animation-iteration-count: infinite; } @keyframes fading { from { opacity: 1; }
to { opacity: 0.2; } }
.media-item { position: relative; width: 128rpx; height: 128rpx; border-radius: 5px; margin-right: 15rpx; } .media-item.empty { border: 1px solid #eee; } .media-item [class*="icon-"] { text-align: center; box-sizing: border-box; color: var(--gray); } .media-item [class*="icon-"]::before { font-size: 80rpx; }
|
代码就不解释了, 放个图, 说明一下界面元素与 diary.wxml 中代码的对应关系, 你可以停下来对照研究一下:
可能你已经感觉到了, 这个界面有点怪怪的…
其实, 程序运行时, 上图中的 ③ ④ 和 那个”麦克风”按钮是不会同时出现的, 同样, “封面图片”和”照相机”按钮也是不会同时出现的.
呵呵, 现在我们只是在做界面设计, 所以把它们都放上去… 后期会使用代码控制它们的”显示”与”隐藏”…
简单介绍一下操作流程:
- 在 ② 所示的输入框中填入日记内容
- 添加封面图片:
- 轻触 ⑤ 中的”照相机”按钮, 拍照 或 相册选取封面图片, 隐藏”照相机”按钮, 显示封面图片预览 (小王子)
- 轻触”预览图片”可重新拍照或相册选取
- 录音:
- 轻触 ⑤ 中的”麦克风”按钮, 出现 ④ 所示的”录音机”, 自动开始录音, 其间录音时长同步计时, 用户轻触”暂停”按钮可暂停录音, 轻触”完成”按钮则结束录音.
- 录音结束则隐藏”录音机”, 同时显示”播放器”, 关于”播放器”上节已详述, 就不罗嗦了.
- 轻触”播放器”右上角的”删除”按钮(红叉叉)可删除当前录制的内容, 继而重复上述过程.
- 在 ⑥ 所示区域输入日记标题
- 轻触 ⑦ 所示的”发表”按钮, 完成日记新增.
24.2 数据模型与数据绑定
与主界面的实现类似, 同样, 我们先找到”发表日记”界面中些”活”的数据… 然后建立他们的数据模型:
Code 24-3: 数据模型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| data: { showRecordPanel: false, isRecording: false, recordDuration: 0, isAudioPlaying: false, isChangingAudioPos: false, audioElapsed: 0, diary: { title: '', cover: '', content: '', audioPath: '', audioDuration: 0, } }
|
实际开发过程中, 上面的数据模型设计并非一蹴而就, 可能是逐步建立起来的. 这里只是为了叙述方便, 所以一股脑全部列出来了…
所以, 没必要有什么挫败感, 呵呵 ~
已经坚持学习到这里了, 相信你应该知道上面这些代码应该放在什么地方了吧 ~
OK ~ 来看看上述数据项怎么用…
Code 24-4: /pages/diary/diary.wxml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| <wxs module="filter" src="../../utils/filter.wxs"></wxs>
<view class="nav" style="padding-top:24px; line-height: 32px;"> <view class='nav-btns'> <view class="icon-left text-lg" catchtap='goBack'></view> </view> <view class="text-center text-lg text-bold">发表日记</view> </view>
<view class="flex flex-column p-sm"> <textarea class="content-box m-tb" placeholder="这么优秀的你, 有什么想跟大家说的 ~" placeholder-class="text-gray" value="{{diary.content}}" bindinput="onFieldInput" data-field="content"> </textarea>
<view wx:if="{{diary.audioPath}}" class="audio flex align-center"> <view class="{{isAudioPlaying?'icon-pause':'icon-play'}} ic-xxl" catchtap="audioPlayOrPause"></view> <view class="flex-sub m-l"> <view class="flex text-xs"> <text>{{filter.sec2Min(audioElapsed)}}</text> <slider class="flex-sub" activeColor="#666" block-size="12" min="0" max="{{diary.audioDuration}}" disabled="{{!isAudioPlaying}}" value="{{audioElapsed}}" bindchange="onChangeAudioPos" bindchanging="onChangingAudioPos" /> <text>{{filter.sec2Min(diary.audioDuration)}}</text> </view> </view> <view class="delete-icon icon-close" bindtap="removeAudio"></view> </view>
<view wx:if="{{showRecordPanel}}" class="recorder flex b r-2x p-sm"> <view class="icon-close text-gray p-r-sm b-r" bindtap="cancelRecord"></view> <view class="flex justify p-l w-full"> <view class="flex flex-sub text-red p-r"> <view wx:if="{{isRecording}}" class="fading icon-record1"></view> <text class="flex-sub">{{filter.sec2Min(recordDuration)}}</text> <view class="{{isRecording?'icon-pause':'icon-play text-green'}}" bindtap="pauseOrResumeRecord"></view> </view> <text class="text-blue b-l p-l-sm" bindtap="finishRecord">完成</text> </view> </view>
<view class="flex start w-full"> <image wx:if="{{diary.cover}}" class="media-item" src="{{diary.cover}}" bindtap="setCover"></image> <view wx:if="{{!diary.cover}}" class="media-item empty" bindtap="setCover"> <view class="icon-photo-add"></view> </view> <view wx:if="{{!diary.audioPath && !showRecordPanel}}" class="media-item empty" bindtap="showRecordPanel"> <view class="icon-record2"></view> </view> </view>
<view class="w-full m-t p"> <view class="flex b-b"> <view class="icon-comment text-gray m-r">添加标题</view> <input class="weui-input" maxlength="30" value="{{diary.title}}" bindinput="onFieldInput" data-field="title" /> </view> </view>
<view class="w-full flex m-t-lg"> <button class="flex-sub bg-gradual-green" bindtap="publishDiary">发 表</button> </view> </view>
|
以上代码主要在 Code 24-1 的基础上添加了数据绑定和事件绑定.
- 第 1 行, 引入”过滤器”, 用于格式化”播放器”中时间的显示, 参见 12 节
- 第 11 行, 绑定日记内容(diary.content), 注意其中的
bindinput="onFieldInput" data-field="content"
绑定”输入事件”, 用于取得用户的日记内容. 详见后文
- 第 14 ~ 24 行, 首页中音频播放器的简化版.
- 第 14 行,
wx:if="{{diary.audioPath}}"
, 只有日记音频已录制(已有音频路径)才显示.
- 第 23 行, 播放器右上角的删除按钮, 回调 removeAudio()
- 其余内容不再赘述, 参见 23 节
- 第 26 ~ 36 行, 录音机面板.
- 第 26 行, wx:if=”“, 由 data.showRecordPanel 控制其显示/隐藏
- 第 27, 32, 34 行分别绑定 取消录音, 暂停/恢复录音, 完成录音 按钮轻触事件. 注意其回调函数名, 后文会用到.
- 第 38 ~ 46 行, 日记封面设置区, 同样注意其中绑定的事件回调函数.
- 第 48 行, 绑定日记标题(diary.title), 注意
bindinput="onFieldInput" data-field="title"
, 绑定”输入事件”, 用于取得用户日记标题, 详见后文
- 第 55 ~ 57 行, 发表日记按钮, 回调 publishDiary()
有点吓人, 这么多事件绑定… 呵呵 ~ 没办法呀, 确实有这么些功能呀, 还能咋整….
24.3 日记内容与标题
来, 我们先挑最简单的来处理…
日记内容和标题都由用户使用键盘输入, 它们的处理方法基本相同, 所以我们可以偷个懒, 用一个回调函数统一处理…
关注 Code 24-4 第 11, 48 行, 它们都绑定了 input 事件, 用户输入时将触发此事件 ( 自已查阅一下微信小程序文档吧 )
需要稍微注意的是, 我们使用 data-field
属性来标识用户现在编辑的是哪个输入框 ?
呵呵, 有点点蒙哦 ~ 看代码吧, 应该一看就明白了…
Code 24-5: /pages/diary/diary.js ( onFieldInput 回调函数)
1 2 3 4 5
|
onFieldInput: function(e) { this.data.diary[e.currentTarget.dataset.field] = e.detail.value; }
|
简单吧 ~
e.currentTarget.dataset.field
: diary.wxml 中 data-filed 中设置的值: content / title
e.detail.value
: 输入框中的内容
在教程的第三部分第 11 节已经吐槽过微信小程序框架所谓的”简单双向绑定”了, 这里就不再吐了… 自行回顾…
24.4 封面图片
放一个文档链接: wx.chooseImage(), 先试试看, 能不能自己搞定吧 ~
在 Code 24-4 中, 第 39, 40 行, 封面图片预览/选取按钮上都进行了轻触事件绑定: bindtap="setCover"
我们来看看 setCover 回调函数的代码吧…
**Code 24-6: /pages/diary/diary.js ( setCover 回调函数 ) **
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| setCover: function() { wx.chooseImage({ count: 1, sizeType: ['original'], sourceType: ['album', 'camera'], success: (res) => { const filePaths = res.tempFilePaths; if (filePaths && filePaths.length) { this.setData({'diary.cover': filePaths[0]}); } } }); }
|
参看上面给的文档链接和代码中的注释, 相信你能看懂 ~
补充一点… 第 10 行, 把取得的图片路径填充到数据模型中的 diary.cover, 此前已完成了数据绑定 (Code 24-4: 第 39 ~ 42 行), 所以当用户选取图片/拍照后将自动刷新界面.
在电脑上调试时玩不了相机拍摄, 你可以使用真机调试/预览试试效果
24.5 录音机
录音机功能的实现使用了微信小程序框架提供的RecorderManagerAPI.
录音机与音频播放器的实现原理类似: 监听用户轻触事件(开始/暂停/结束录音) , 继而调用 RecorderManager 中相应的 API, 最终实现所需功能.
这里列出了教程中使用到的API, 详细介绍请参看上述文档链接…
(1) const recorderManager = wx.getRecorderManager() : 获得 RecorderManager 实例(对象)
(2) recorderManager.start(options) : 开始录音
(3) recorderManager.pause() : 暂停录音
(4) recorderManager.resume() : 恢复(继续)录音
(5) recorderManager.stop() : 停止录音
(6) 相应地, 我们还需要监听 recorderManager 相关的事件(onstart, onPause, onResume, onStop), 以触发回调参数, 作相应处理.
这里, 为了获得较好的用户体验, 在录音过程中将显示一个录音计时器, 以提示用户目录正在录音, 详见代码中的注释.
OK ~ 开始吧 ~
下面只给出了录音机部分的代码, 不是 diary.js 的完整代码, 如果你跟着做的话, 需要把下面的代码挑出来, 放到你代码中的合适位置.
**Code 24-7: /pages/diary/diary.js ( 录音机功能实现部分 ) **
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| const recorderManager = wx.getRecorderManager();
const recordOptions = { duration: 600000, sampleRate: 44100, encodeBitRate: 64000, format: 'mp3' };
let recordTimer = null;
Page({ state: { isCancelRecord: false }, onLoad: function () { recorderManager.onStart(() => { this.setData({ isRecording: true }); }); recorderManager.onPause(() => { this.setData({ isRecording: false }); }); recorderManager.onResume(() => { this.setData({ isRecording: true }); }); recorderManager.onStop((res) => { this.setData({ isRecording: false, 'diary.audioPath': this.state.isCancelRecord ? null : res.tempFilePath, 'diary.audioDuration': this.state.isCancelRecord ? 0 : Math.floor(res.duration / 1000) }); this.closeRecordPanel(); }); }, showRecordPanel: function() { this.setData({ showRecordPanel: true, recordDuration: 0 }); this.state.isCancelRecord = false; recordTimer = setInterval(() => { if (this.data.isRecording) { this.setData({recordDuration: this.data.recordDuration + 1}); } }, 1000); recorderManager.start(recordOptions); }, closeRecordPanel: function() { this.setData({ showRecordPanel: false }); clearInterval(recordTimer); }, pauseOrResumeRecord: function() { if (this.data.isRecording) { recorderManager.pause(); } else { recorderManager.resume(); } }, cancelRecord: function() { if (this.data.isRecording) { this.state.isCancelRecord = true; recorderManager.stop(); } }, finishRecord: function() { recorderManager.stop(); } })
|
上述仅是录音机相关功能的代码, 其它部分的代码此处省略…
代码大致可分作 3 个部分:
准备工作:
- 第 2 行, 初始化 RecorderManager 对象 (录音机)
- 第 5 ~ 10 行, 准备录音参数, 这里中是使用一个对象(recordOptions)将相关配置存储起来, 真正的使用是在录音开始时(第 48 行)
监听 RecorderManager (录音机)事件, 第 20 ~ 35 行. 注意, 这部分代码写在页面的 onLoad 事件回调中.
第 20 ~ 25 行, 对录音机的开始录音, 暂停录音, 恢复录音事件分别进行监听, 所做的事件很简单: 更新 data.isRecording 标志的状态. 结合前面在 dairy.wxml 中的数据绑定, 即可实现页面的相应变化. (参见Code 24-4: 26 ~ 36 行)
第 27 ~ 35 行, 录音完成事件的处理(回调), 主要做了 3 件事:
第 29 行, 更新 data.isRecording 状态标志为 false (非录音中)
第 30 ~ 31 行, 记录下录音文件的保存路径及录音时长. 稍注意, 记录录音文件路径时根据 state.isCancelRecord 值进行了简单处理: 若因用户取消录音而导致的录音结束, 则不是记录文件路径(null). 参看第 66 行, 当用户点击了录音面板左侧的”叉”, 即取消录音时, 将 state.isCancelRecord 值记录为 true
也许你已经注意到了, 在上述的代码中, 出现了 2 种形式的数据, 第 1 种是此前我们常用到的, 将数据话在 page.data 属性中, 而第 2 种则使用是放在自定义属性中( 第16 ~ 18行, this.state, 也不一定非得叫”state”, 你可以取你自己喜欢的名字)
那么什么时候该用第 1 中方式, 什么时候又使用第 2 种方式呢?
一般而言, 若此数据需绑定到页面中, 则使用第 1 种方式, 否则使用第 2 种方式, 有利用简化代码, 同步提高小程序界面的处理效率.
其实, 还有第 3 种方式, 看一下第 13 行的代码你就明白, 呵呵 ~
第 34 行, 关闭录音机面板, 这里调用第 51 ~ 54 行的自定义函数 closeRecordPanel(), 参见后文
响应用户操作, 第 37 ~ 91 行. 代码中已有详细的注释, 可自行对照注释先过一遍. 下面重点说一下需要注意的地方…
录音计时器的实现: 在录音过程中, 页面上将显示一个录音计时器? 重点关注第 41 ~ 46 行的代码.
其实, 说来也简单… 在打开录音面板时使用 setInterval()
启动一个时钟, 每1秒触发一次回调, 让计时数值 data.recordDuration 累加 1
只是注意, 在录音暂停时 ( data.isRecording = false ), 钟还在走, 但计时数值不作累加 (第 43 行). 前面提到, data.isRecording 的值在录音开始/暂停/恢复/停止事件的回调函数中均进行了更新.
当录音面板关闭时(用户点”叉”/ 完成录音), 将上述计时时钟释放(第 53 行)
分析 diary.wxml 中的代码应该可以发现, 若 data.diary.audioPath 值不为空时, 则界面上将自动呈现音频”播放器”, 相关功能下节继续详述…
学过 JavaScript 的同学都知道, 其实 setInterval(), setTimeout() 之类的东东并非微信小程序的新玩意, 它们是 JavaScript 世界本来就有的东西.
不知你有没有注意到, 在本部分出现的很多事件回调函数中我们在引用 page 对象时都直接使用了 this, 例如: 第 21, 44 行, this.setData( … )
如果你对前面的内容(16.3节) 还有点点印象, 在回调函数中, 代码运行时的 scope 不再是 page, 因此在回调函数中使用 this 将不能获得 page 对象的引用. 故而, 我们通常需要使用点”奇技淫巧”: 在调用 API 前把 this 暂存一下 ( 类似: const _this = this
), 在回调函数中则使用 _this.setData( ... )
如果你研究别人写的代码, 但是微信官方文档, 可以看到, 此技巧的使用非常普遍…
而回顾本部分中出现的大量回调函数, 似乎并没有用到上述的”技巧”, 是因为没必要吗?
呵呵 ~ 其实并非如此, 关键在于回调函数的定义, 本部分我们使用了箭头函数: () => { ... }
的形式来定义, 而非匿名函数: function() { … }
简单说, 箭头函数中的 this 是”静态绑定”, 即定义函数时即绑定, 对于上述代码刚好就绑定了我们需要的 page 对象. 而匿名函数则使用的是”运行时绑定”, 所以就绑飞了…
所以, 以后就用箭头函数就好了, ES6中已经给你提供了这么好的机制你不用, 非得把自己搞得土哩土气的, 何苦呢 ~
24.6 音频播放器
这里放置一个音频播放器的目的是让用户录音完成后, 可以即时播放一下, 听听自己的录制的音频效果…
本页面中音频播放器其实是第 23 节播放器的简化版, 很多代码都是直接从第 23 节的代码中复制过来的, 所以没必要的重复解释本节就省略了, 有必要的话, 回看 23 节…
下面只给出了音频播放器部分的代码, 不是 diary.js 的完整代码, 如果你跟着做的话, 需要把下面的代码挑出来, 放到你代码中的合适位置.
**Code 24-8: /pages/diary/diary.js (音频播放器功能实现) **
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| const audioPlayer = wx.getBackgroundAudioManager();
Page({ onLoad: function () { audioPlayer.onPlay(() => { this.setData({isAudioPlaying: true}); }); audioPlayer.onPause(() => { this.setData({isAudioPlaying: false}); }); audioPlayer.onStop(() => { this.setData({isAudioPlaying: false, audioElapsed: 0}); }); audioPlayer.onEnded(() => { this.setData({isAudioPlaying: false, audioElapsed: 0}); }); audioPlayer.onTimeUpdate(() => { if (!this.data.isChangingAudioPos) { this.setData({audioElapsed: audioPlayer.currentTime}); } }); }, removeAudio: function() { this.setData({ 'diary.audioPath': null, 'diary.audioDuration': 0 }); }, audioPlayOrPause: function (e) { if (this.data.isAudioPlaying) { audioPlayer.pause(); } else { audioPlayer.src = this.data.diary.audioPath; audioPlayer.title = '您的录音'; audioPlayer.play(); } }, onChangingAudioPos: function(e) { this.setData({ isChangingAudioPos: true, audioElapsed: e.detail.value }); }, onChangeAudioPos: function(e) { audioPlayer.seek(e.detail.value); if (audioPlayer.paused) audioPlayer.play(); this.setData({ isChangingAudioPos: false}); } })
|
如果第 23 节你学习得还不错, 相信上面的代码看起来会很轻松…
这里补充说明一下”新功能”: 删除录音, 第 23 ~ 28 行.
很简单, 用户轻触”删除录音”按钮(音频播放器右上角的红叉叉)时, 触发此段代码. 代码中直接将 diary.audioPath 置 null, 即不记录音频文件路径. 而将 diary.audioDuration 置 0
可能你会有疑问, 就这样的行了? 难道不用从文件系统中真的删除已录制好的音频文件吗?
不知你注意到没有, 录制完成的音频文件存储位置是在一个临时文件夹下(设个断点看看), 系统应会自动清理此文件夹下, 所以由它丢着就行了… (我是这样理解滴, 呵呵~)
24.7 整合起来
现在, 我们把上面整个第 24 节的代码组合一下, 看看最终效果吧…
**Code 24-9: /pages/diary/diary.js (本节完整代码) **
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
| const recorderManager = wx.getRecorderManager();
const audioPlayer = wx.getBackgroundAudioManager();
const recordOptions = { duration: 600000, sampleRate: 44100, encodeBitRate: 64000, format: 'mp3' };
let recordTimer = null;
Page({ data: { showRecordPanel: false, isRecording: false, recordDuration: 0, isAudioPlaying: false, isChangingAudioPos: false, audioElapsed: 0, diary: { title: '', cover: '', content: '', audioPath: '', audioDuration: 0, } }, state: { isCancelRecord: false }, onLoad: function () { recorderManager.onStart(() => { this.setData({ isRecording: true }); }); recorderManager.onPause(() => { this.setData({ isRecording: false }); }); recorderManager.onResume(() => { this.setData({ isRecording: true }); }); recorderManager.onStop((res) => { this.setData({ isRecording: false, 'diary.audioPath': this.state.isCancelRecord ? null : res.tempFilePath, 'diary.audioDuration': this.state.isCancelRecord ? 0 : Math.floor(res.duration / 1000) }); this.closeRecordPanel(); });
audioPlayer.onPlay(() => { this.setData({isAudioPlaying: true}); }); audioPlayer.onPause(() => { this.setData({isAudioPlaying: false}); }); audioPlayer.onStop(() => { this.setData({isAudioPlaying: false, audioElapsed: 0}); }); audioPlayer.onEnded(() => { this.setData({isAudioPlaying: false, audioElapsed: 0}); }); audioPlayer.onTimeUpdate(() => { if (!this.data.isChangingAudioPos) { this.setData({audioElapsed: audioPlayer.currentTime}); } }); }, onFieldInput: function(e) { this.data.diary[e.currentTarget.dataset.field] = e.detail.value; }, setCover: function() { wx.chooseImage({ count: 1, sizeType: ['original'], sourceType: ['album', 'camera'], success: (res) => { const filePaths = res.tempFilePaths; if (filePaths && filePaths.length) { this.setData({'diary.cover': filePaths[0]}); } } }); }, showRecordPanel: function() { this.setData({ showRecordPanel: true, recordDuration: 0 }); this.state.isCancelRecord = false; recordTimer = setInterval(()=>{ if (this.data.isRecording) { this.setData({recordDuration: this.data.recordDuration + 1}); } }, 1000); recorderManager.start(recordOptions); }, closeRecordPanel: function() { this.setData({ showRecordPanel: false }); clearInterval(recordTimer); }, pauseOrResumeRecord: function() { if (this.data.isRecording) { recorderManager.pause(); } else { recorderManager.resume(); } }, cancelRecord: function() { if (this.data.isRecording) { this.state.isCancelRecord = true; recorderManager.stop(); } }, finishRecord: function() { recorderManager.stop(); }, removeAudio: function() { this.setData({ 'diary.audioPath': null, 'diary.audioDuration': 0 }); }, audioPlayOrPause: function (e) { if (this.data.isAudioPlaying) { audioPlayer.pause(); } else { audioPlayer.src = this.data.diary.audioPath; audioPlayer.title = '您的录音'; audioPlayer.play(); } }, onChangingAudioPos: function(e) { this.setData({ isChangingAudioPos: true, audioElapsed: e.detail.value }); }, onChangeAudioPos: function(e) { audioPlayer.seek(e.detail.value); if (audioPlayer.paused) audioPlayer.play(); this.setData({ isChangingAudioPos: false}); }, goBack: function() { wx.navigateBack(); }, publishDiary: function() { const diary = this.data.diary; console.log(diary); } })
|
大部分代码都已经在前面的各小节中解释过了, 这里只是把它们粘在一起…
悄悄加了点新东西:
第 157 ~ 160 行, 返回主界面功能实现. 很简单, 用户轻触发表日记导航栏上的 <
按钮, 执行wx.navigateBack()
可返回到上一个界面(主界面).
因为本部分开始时, 为调试方便, 把发表日记页面设置成了”默认主页”, 所以你现在还需要重新打开app.json 手动交换一下 /pages/index/index 和 /pages/diary/diary 的次序才能看到效果 ( 参见第 24.1 节 )
第 161 ~ 165 行, 轻触发表日记按钮的回调函数. 这里只是将最终得到的 diary 数据输出到控制台, 观察一下结果… 真正的发表日记功能需要结合服务器端来实现, 将在下一部分详述.
OK ~ 现在, 你应该拥有了”发布日记”功能界面的完整代码: Code 24-1, Code 24-9
赶快试试效果吧 ~ 如果一切正常, 可以奖励自己去吃顿肉了 ~
25. 自定义导航栏
知道你已经很累了 ~ 放心, 这部分很简单, 10 分钟就可以学会…
为了让我们的小程序首页看上去”高大上”一些, 在第二部分的 8.3 节, 我们把小程序的默认导航栏去掉了 ( app.json: “navigationStyle”: “custom” )
这样一来, 小程序所有的页面都没了那个默认的丑陋的导航栏. 但是也带来了一个新问题…
像发表日记这样的页面, 我们就需要自己手动去实现那个导航栏, 否则用户就无法退回到首页了…
于是, 你可以在 Code 24-1 的开头看到这样的段:
1 2 3 4 5 6
| <view class="nav" style="padding-top:24px; line-height: 32px;"> <view class='nav-btns'> <view class="icon-left"></view> </view> <view class="text-center text-bold">发表日记</view> </view>
|
这就是我们自己定义的导航栏…
注意第 1 行中”硬编码”的部分: padding-top:24px; line-height: 32px;
这是为了让我们自定义导航栏中的内容对齐小程序中那个去不掉的”胶囊按钮” ( 下图右侧那坨 ) …
那么, 问题来了, 我不知道在你的手机上对齐了吗? 反正我手机上是对齐了, 呵呵 ~
事实上, 不同的操作系统(Android/IOS)中这个”胶囊按钮”的位置和大小是不一样的, 所以, 上面代码中的”硬编码”做法实在不妥… 即: 24px, 32px 不应该”写死”.
我们应该通过代码去获取用户手机上”胶囊按钮”的真实位置和大小, 从而定位我们的导航栏…
道理讲清楚了, 动手吧 ~
25.1 获取系统信息定位导航栏
打开 app.js
文件, 写入如下代码:
Code 25-1: app.js
1 2 3 4 5 6 7 8 9
| App({ globalData: { capsuleRect: {} }, onLaunch: function () { this.globalData.capsuleRect = wx.getMenuButtonBoundingClientRect(); } })
|
自从第一部分介绍过这个 app.js 文件是小程序的主入口后就没碰过它了…
注意第 7 行, 这里使用了微信 API wx.getMenuButtonBoundingClientRect()
来获取胶囊按钮信息, 文档: wx.getMenuButtonBoundingClientRect
可在自已设断点观察一下, 获取到的数据大概是这样: { width: 87, height: 32, left: 278, top: 24, right: 365, bottom: 56 }
注意到了吗, 其中的 height: 32, top: 24 就前面所说的”硬编码”使用的值.
我们将取到的胶囊按钮信息存储在全局共享数据域(app.globalData)中: app.globalData.capsuleRect
, 以便在整个小程序的世界中共用.
微信小程序框架还提供了经常会使用到的 API: wx.getSystemInfo()
, 用于获得系统相关信息, 建议抽空看看: wx.getSystemInfo
那么… 接下来… 在”发表日记”页面中, 如何获得 app.globalData.capsuleRect 呢?
修改 diary.js 代码, 把 app.globalData.capsuleRect 暴露给页面…
Code 25-2: /pages/diary/diary.js 局部
1 2 3 4 5 6 7
| const app = getApp();
Page({ data: { capsuleRect: app.globalData.capsuleRect } });
|
呵呵 ~ 极简局部代码…
在页面文件(.wxml)中是不能直接引用 app.globalData 的, 所以, 这里我们得使用 page.data “过渡” 一下 (第 5 行)
同时, 在上面的代码中, 我也又学会一个新招式: 在页面中可使用 getApp()
取得对小程序 app 对象的引用.
接下来, 当然是修改页面文件, 进行数据绑定了…
Code 25-3: /pages/diary/diary.wxml 开头
1 2 3 4 5 6 7 8
| ... <view class="nav" style="padding-top:{{capsuleRect.top}}px; line-height: {{capsuleRect.height}}px;"> <view class='nav-btns'> <view class="icon-left"></view> </view> <view class="text-center text-bold">发表日记</view> </view> ...
|
关注第 2 行. 不解释! 否则完全是在侮辱你的智商…
好了, 如果此前你”发表日记”页面的导航栏未对齐胶囊按钮, 那么, 现在应该是对齐了 ~
25.2 使用自定义组件封装导航栏
试想, 如果你的小程序中有很多个页面, 每个页面都像前一小节(25.1)所述的方式去搞那个导航栏, 那也太累了… 既不通用, 也不灵活.
微信小程序框架中提供了一个机制, 可以让我们自定义组件…
其实, 此前我们在 .wxml 中使用到的 view, text … 都是组件, 只是这些组件是微信已经定义好的, 我们直接使用即可.
所谓自定义组件, 通俗点说….. 把类似 Code 25-3 中那坨东西封装一下, 变成所谓的自定义组件, 这样的话, 如果需要, 直接使用即可. 你可以认为组件即是页面中的一坨东西.
嘿嘿, 开干吧 ~ 学完本小节你肯定就知道什么是自定义组件了…
(1) 在小程序项目根目录下新建文件夹 components
(2) 在 components 文件夹中再新建文件夹 navigationBar
(3) 右键单击 navigationBar 文件夹图标, 选择 “新建 Componnet”, 名称输入 navigationBar
这样你就在/components/navigationBar
下新建了一个名为 navigationBar
的组件
为防万一, 给个图吧 ~
可以看到, 这个所谓组件(component)的文件结构与页面(page)的非常相似… 不用详细解释了吧…
微信小程序文档中有关于”自定义组件”的详细介绍: https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/
这里, 我们边做边学…
编辑 navigationBar.wxml 文件, 把 /pages/diary/diary.wxml 中导航栏部分的代码(Code 25-3) 剪切 过来…
Code 25-4: /components/navigationBar/navigationBar.wxml
1 2 3 4 5 6 7 8 9
| <view class="nav" style="padding-top:{{capsuleRect.top}}px; line-height: {{capsuleRect.height}}px;"> <view class='nav-btns'> <view class="icon-left text-lg" catchtap="goBack"></view> </view> <view class="text-center text-lg text-bold flex center"> <slot></slot> <text>{{title}}</text> </view> </view>
|
以上代码在 Code 25-3 的基础上稍作修改, 增加了第 6, 7 两行.
- 第 6 行:
<slot></slot>
这是一个插槽, 嘿嘿, 什么鬼? 后面你会知道…
- 第 7 行: 数据绑定: 实例教程 - 微信小程序 (六), 用于显示可变的页面标题, 例如: “发布日记”. 可以想象得到, 在 navigationBar.js 中一定有一个类似 data.title 的东东.
同样, 我们需要把 diary.wxss 中导航栏部分的样式表搬过来:
Code 25-5: /components/navigationBar/navigationBar.wxss
1 2 3 4 5 6 7
| .nav { position: relative; } .nav .nav-btns { position: absolute; padding-left: 15rpx; }
|
这些代码没什么变化, 只是搬了个家.
关键的来了… 打开 navigationBar.json
看一下, 应该有如下代码:
Code 25-6: /components/navigationBar/navigationBar.json
1 2 3 4
| { "component": true, "usingComponents": {} }
|
其实, 对于这里我们也不需要修改什么. 只是需要注意, 这里的第 2 行 "component": true
, 说明当前这个 navigationBar 是个组件(Component), 而不是普通的页面(Page)
最后, 打开 navigationBar.js
, 写入如下代码:
Code 25-7: /components/navigationBar/navigationBar.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const app = getApp();
Component({ options: { addGlobalClass: true, }, properties: { title: { type: String, value: '默认标题' } }, data: { capsuleRect: app.globalData.capsuleRect }, methods: { goBack: function() { wx.navigateBack(); } } })
|
嘿嘿, 这个文件就复杂了… 不过, 你不妨试试, 应该能猜到每行代码是干什么用的…
第 2 行, 引入 app 对象, 这和原先写在 diary.js 中的作用是一样的, 然后… 在第 15 行, 将”胶囊按钮”的信息暴露给页面 (navigationBar.wxml)
第 5 ~ 7 行, 这里组件一配置信息(options), addGlobalClass: true
意为需要把全局样式类引入到当前组件
组件是相对独立的单元, 默认情况下它不会主动使用组件外定义的样式, 比如我们写在 app.wxss, custom.wxss 中的样式. 但是, 此处显然在 navigationBar.wxml 中需要使用到全局样式, 因此, 加了第 6 行的配置, 以引入全局样式.
组件, 说白了是一个对象(Object), 如果你还有点面向对象程序设计的基础, 应该知道… 对象应有 2 类成员: 属性(property, 数据) 和 方法(method, 功能).
明白了这个道理之后, 看余下的代码就会轻松得多了…
第 8 行, properties 定义了此组件的属性集合. 当然, 这里我们的 navigationBar 组件只有一个属性: title (第 9 ~ 12 行), 此值被绑定到了组件的页面文件中(Code 25-4, 第7 行). 另外它也将承接宿主传递过来的参数值 (后面再说…)
第 10 ~ 11 行的含意自己猜猜吧 ~
第 17 行, methods 定义了此组件的方法集合, 与 Page 中的方法类似 (如: onLoad, onAddDiary … )
这里, 我们把原先 diary.js 中的 goBack 函数的代码搬了过来, 实现轻触 <
返回前一页面. (参见: Code 25-4, 第 3 行, catchtap)
上文中提到把代码从 diary.wxml / diary.js 中 “搬过来” 的地方, 要确实去搬哦 ~ 即: 要删除原先 diary.wxml / diary.js 中相应的代码.
好了, 最后来看一下, 我们的自定义组件(自定义导航栏) 怎么使用…
编辑 diary.json 中的代码:
Code 25-8: /pages/diary/diary.json
1 2 3 4 5
| { "usingComponents": { "navigationBar": "/components/navigationBar/navigationBar" } }
|
在原先代码的基础上添加了第 3 行, 即: 说明在”发表日记”页面中, 我们将使用 navigationBar 组件.
注意第 3 行的后半部分"/components/navigationBar/navigationBar"
是组件的路径, 而前半部分 "navigationBar"
则是引入了此组件后为其取的名字, 你可以随意取.
来看看发布日记页面文件的变化…
Code 25-9: /pages/diary/diary.wxml (局部)
1 2 3 4 5 6 7 8 9 10
| <wxs module="filter" src="../../utils/filter.wxs"></wxs>
<navigationBar title="发表日记"> <view class="icon-fav text-pink"></view> </navigationBar>
<view class="flex flex-column p-sm"> <textarea class="content-box m-tb" placeholder="这么优秀的你, 有什么想跟大家说的 ~" placeholder-class="text-gray" value="{{diary.content}}" bindinput="onFieldInput" data-field="content"> </textarea> ....
|
关键的是第 3 ~ 5 行, 其余代码只是辅助你定位要添加的代码应该放在什么地方.
<navigationBar></navigationBar>
引入自定义组件. 注意, 这里的标签名应与 Code 25-8 中第 3 行取的那个名字一致.
titile="发表日记"
, 猜到了吧, 呵呵~ 这里的值将传递给组件的 title 属性 (Code 25-7, 第 9 ~ 12 行), 最终又被绑定到的组件的界面上.
第 4 行这小段又有什么用呢? 记得此前我们提到过一个称作”插槽”的东东吗? 参见 Code 25-4, 第 6 行. 嘿嘿, 对了… 第 4 行就是插在”插槽”上的东西.
组件标签内部的所有元素均将被”插”到”插槽”上, 只是这里我们只是插了一个字体图标而已 (最终效果图”发表日记”前的小星星)
好了, 安利一下 ~
- 自定义组件是代码复用的一个重要机制. 试想, 如果你的小程序有 N 个页面都需要导航栏, 是不是只需要像上面一样引入导航栏组件即可…
- 你可以把项目中常用的模块(元素)封装成自定义组件, 这样可以有效避免”重复造轮子”
- 要不, 你试试能否把”音频播放器”封装成自定义组件, 毕竟, 目前为止, 我们已经在两个页面上使用到了”音频播放器”…
篇幅所限…… 其实是懒 ~ 本文只简要介绍了自定义组件的定义和使用方法, 其实, 在微信小程序框架中, 自定义组件可以玩出的花样还很多…
比如, 自定义组件(navigationBar) 与宿主 (diary) 之间的通信, 自定义组件之间的嵌套与通信….
可以说, 你现在看到的微信小程序框架中现有的那些看似高级的组件, slider, map …. 都是使用自定义组件这套机制定义出来的, 只是这些是微信框架的开发人员完成的而已.
总之, 很有必要认真研究自定义组件的使用, 所以抽空看看官方文档.
当然, 更重要的是在项目中用好它. 但悲催的是, 实际公司中的项目, 由于种种原因, 比如: 老板催得急, 程序员交差了事… 大多数情况下, 程序员们很可能宁愿使用最原始的”复制/粘贴”大法, 也不愿意缓下来做简易的”封装”…… 正所谓, 理想很丰满, 现实很骨感 ~
不然, 你回顾一下目前为止我们已经使用过的微信小程序框架, 以及你看过的微信小程序官方文档…… 就算所谓国内著名的 BAT 中的 T ( Tencent, 腾讯 ) 所产出的东西中也处处槽点不断… 呵呵, 这样说, 你是不是舒服些了 ~
说到这里, 强烈的职业责任感促使我道貌岸然地站出来说一句, 本教程中的代码均已尽最大努力反复推敲, 保证浅显易懂的同时尽量严谨, 还适时给出了风险提示, 尽管如此, 总有不周之处… 如果热心读者们发现错误或有不妥之处还请指正! 有小奖励哦 ~ 呵呵 ~
是不是感觉该出字幕了, 哈哈 ~
这是目前为止的完整代码, 仅供参考: daily-reading_part6.zip
下节再见 ~