上一次的《微信小程式之小豆瓣圖書》製作了一個圖書的查詢功能,只是簡單地應用到了網路請求,其他大多數小程式應有的知識。而本次的示例是`知乎日報`,功能點比較多,頁面也比上次複雜了許多。在我編寫這個DEMO之前,網上已經有很多網友弄出了相同的DEMO,也是非常不錯的,畢竟這個案例很經典,有比較完整的AP... ...
上一次的《微信小程式之小豆瓣圖書》製作了一個圖書的查詢功能,只是簡單地應用到了網路請求,其他大多數小程式應有的知識。而本次的示例是知乎日報
,功能點比較多,頁面也比上次複雜了許多。在我編寫這個DEMO之前,網上已經有很多網友弄出了相同的DEMO,也是非常不錯的,畢竟這個案例很經典,有比較完整的API,很值得模仿學習。本次個人的DEMO也算是一次小小的練習吧。
由於知乎日報是一個資訊類的App,UI的佈局主要是以資訊列表頁、資訊詳情頁和評論頁為主,當然本次也附帶了應用設置頁,不過現階段功能尚未編寫,過段時間會更新補充,繼續完善。
API分析
本次應用使用了知乎日報的API,相比上次豆瓣圖書的數量比較多了,但是部分仍然有限制,而且自己沒有找到評論介面的分頁參數,所以評論這塊沒有做數據的分頁。
以下是使用到的具體API,更加詳細參數和返回結構可參照網上網友分享的 知乎日報-API-分析 ,在此就不做再次分析了。
啟動界面圖片
http://news-at.zhihu.com/api/4/start-image/{size}
參數 | 說明 |
---|---|
size | 圖片尺寸,格式:寬*高。例如: 768*1024 |
獲取剛進入應用時的顯示封面,可以根據傳遞的尺寸參數來獲取適配用戶屏幕的封面。
獲取最新日報
http://news-at.zhihu.com/api/4/news/latest
返回的數據用於日報的首頁列表,首頁的結構有上下部分,上部分是圖片滑動模塊,用於展示熱門日報,下部分是首頁日報列表,以上介面返回的數據有熱門日報和首頁日報
獲取日報詳細
http://news-at.zhihu.com/api/4/news/{id}
參數 | 說明 |
---|---|
id | 日報id |
在點擊日報列表也的日報項時,需要跳轉到日報詳情頁展示日報的具體信息,這個介面用來獲取日報的展示封面和具體內容。
歷史日報
http://news.at.zhihu.com/api/4/news/before/{date}
參數 | 說明 |
---|---|
date | 年月日格式時間yyyyMMdd,例如:20150903、20161202 |
這個介面也是用與首頁列表的日報展示,但是不同的是此介面需要傳一個日期參數,如20150804
格式。獲取最新日報介面只能獲取當天的日報列表,如果需要獲取前天或者更久之前的日報,則需要這個介面單獨獲取。
日報額外信息
http://news-at.zhihu.com/api/4/story-extra/{id}
參數 | 說明 |
---|---|
id | 日報id |
在日報詳情頁面中,不僅要展示日報的內容,好需要額外獲取此日報的評論數目和推薦人數等額外信息。
日報長評
http://news-at.zhihu.com/api/4/story/{id}/long-comments
參數 | 說明 |
---|---|
id | 日報id |
日報的評論頁面展示長評用到的介面(沒有找到分頁參數,分頁沒有做)
日報短評
http://news-at.zhihu.com/api/4/story/{id}/short-comments
參數 | 說明 |
---|---|
id | 日報id |
日報的評論頁面展示段評用到的介面(沒有找到分頁參數,分頁沒有做)
主題日報欄目列表
http://news-at.zhihu.com/api/4/themes
主頁的側邊欄顯示有主題日報的列表,需要通過這個介面獲取主題日報欄目列表
主題日報具體內容列表
http://news-at.zhihu.com/api/4/theme/{themeId}
參數 | 說明 |
---|---|
themeId | 主題日報欄目id |
在主頁側欄點擊主題日報進入主題日報的內容頁,需要展示此主題日報下的日報列表。
代碼編寫
啟動頁
作為一個仿製知乎日報的偽APP,高大上的啟動封面是必須的,哈哈。啟動頁面很簡單,請求一個應用啟動封面介面,獲取封面路徑和版權信息。當進入頁面,在onLoad
事件中獲取屏幕的寬和高來請求適合尺寸的圖片,在onReady
中請求載入圖片,在請求成果之後,延遲2s進入首頁,防止頁面一閃而過。
onLoad: function( options ) {
var _this = this;
wx.getSystemInfo( {
success: function( res ) {
_this.setData( {
screenHeight: res.windowHeight,
screenWidth: res.windowWidth,
});
}
});
},
onReady: function() {
var _this = this;
var size = this.data.screenWidth + '*' + this.data.screenHeight;
requests.getSplashCover( size, ( data ) => {
_this.setData( { splash: data });
}, null, () => {
toIndexPage.call(_this);
});
}
/**
* 跳轉到首頁
*/
function toIndexPage() {
setTimeout( function() {
wx.redirectTo( {
url: '../index/index'
});
}, 2000 );
}
首頁
輪播圖
首頁頂部需要用到輪播圖來展示熱門日報,小程式中的Swipe
組件可以實現。
<swiper class="index-swiper" indicator-dots="true" interval="10000">
<block wx:for="{{sliderData}}">
<swiper-item data-id="{{item.id}}" bindtap="toDetailPage">
<image mode="aspectFill" src="{{item.image}}" style="width:100%" />
<view class="mask"></view>
<view class="desc"><text>{{item.title}}</text></view>
</swiper-item>
</block>
</swiper>
所有的內容都必須要在swiper-item
標簽中,因為我們的圖片不止有一張,而是有多個熱門日報信息,需要用迴圈來展示數據。這裡需要指定的是image
里的屬性mode
設置為aspectFill
是為了適應組件的寬度,這需要犧牲他的高度,即有可能裁剪,但這是最好的展示效果。toDetailPage
是點擊事件,觸發跳轉到日報詳情頁。在跳轉到日報詳情頁需要附帶日報的id
過去,我們在迴圈列表的時候把當前日報的id
存到標簽的data
中,用data-id
標識,這有點類似與html5中的data-*
API。當在這個標簽上發生點擊事件的時候,我們可以通過Event.currentTarget.dataset.id
來獲取data-id
的值。
日報列表
列表的佈局大同小異,不過這裡的列表涉及到分頁,我們可以毫不猶豫地使用scroll-view
組件,它的scrolltolower
是非常好用的,當組件滾動到底部就會觸發這個事件。上次的小豆瓣圖書也是使用了這個組件分頁。不過這次的分頁動畫跟上次不一樣,而是用一個附帶旋轉動畫的刷新圖標,使用官方的動畫api來實現旋轉。
<view class="refresh-block" wx:if="{{loadingMore}}">
<image animation="{{refreshAnimation}}" src="../../images/refresh.png"></image>
</view>
代碼中有一個顯眼的animation
屬性,這個屬性就是用來控制動畫的。
/**
* 旋轉上拉載入圖標
*/
function updateRefreshIcon() {
var deg = 360;
var _this = this;
var animation = wx.createAnimation( {
duration: 1000
});
var timer = setInterval( function() {
if( !_this.data.loadingMore )
clearInterval( timer );
animation.rotateZ( deg ).step();
deg += 360;
_this.setData( {
refreshAnimation: animation.export()
})
}, 1000 );
}
當列表載入數據時,給動畫設置一個時長duration
,然後按Z軸旋轉,即垂直方向旋轉rotateZ
,每次旋轉360度,周期是1000毫秒。
列表的佈局跟上次的小豆瓣圖書的結構差不多,用到了迴圈結構wx:for
和判斷語句wx:if
、 wx:else
來控制不同的展示方向。
<view class="common-list">
<block wx:for="{{pageData}}">
<view class="list-item {{item.images[0] ? 'has-img': ''}}" wx:if="{{item.type != 3}}" data-id="{{item.id}}" bindtap="toDetailPage">
<view class="content">
<text>{{item.title}}</text>
</view>
<image wx:if="{{item.images[0]}}" src="{{item.images[0]}}" class="cover"></image>
</view>
<view class="list-spliter" wx:else>
<text>{{item.title}}</text>
</view>
</block>
</view>
class="list-spliter"
這塊是用來顯示日期,列表中的日報只要不是同一天的記錄,就在中間插入一條日期顯示塊。在列表項中有一個三元運算判斷輸出具體的class{{item.images[0] ? 'has-img': ''}}
,是因為列表中可能沒有圖片,因此需要判定當前有沒有圖片,沒有圖片就不添加class為has-img
來控制帶有圖片列表項的佈局。
浮動按鈕
因為小程式中沒有側欄組件,無法做到側滑手勢顯示側欄(本人發現touchstart事件和tap事件有衝突,無法實現出手勢側滑判斷,所以沒有用側滑手勢,可能是本人理解太淺了,沒有發現解決方法,嘿嘿...),浮動按鈕的樣式參照了Android中的FloatAction經典按鈕。可以浮動在界面上,還可以滑動到任意位置,背景為稍微透明。
<view class="float-action" bindtap="ballClickEvent" style="opacity: {{ballOpacity}};bottom:{{ballBottom}}px;right:{{ballRight}}px;" bindtouchmove="ballMoveEvent">
</view>
.float-action {
position: absolute;
bottom: 20px;
right: 30px;
width: 50px;
height: 50px;
border-radius: 50%;
box-shadow: 2px 2px 10px #AAA;
background: #1891D4;
z-index: 100;
}
按鈕的樣式隨便弄了一下,寬高用了px
是因為後面的移動判斷需要獲取屏幕的寬高信息,這些信息的單位是px
。wxml綁定了點擊事件和移動事件,點擊事件是控制側欄彈出,滑動事件是按鈕移動。
//浮動球移動事件
ballMoveEvent: function( e ) {
var touchs = e.touches[ 0 ];
var pageX = touchs.pageX;
var pageY = touchs.pageY;
if( pageX < 25 ) return;
if( pageX > this.data.screenWidth - 25 ) return;
if( this.data.screenHeight - pageY <= 25 ) return;
if( pageY <= 25 ) return;
var x = this.data.screenWidth - pageX - 25;
var y = this.data.screenHeight - pageY - 25;
this.setData( {
ballBottom: y,
ballRight: x
});
}
touchmove
事件中的會傳遞一個event
參數,通過這個參數可以獲取到當前手勢滑動到的具體坐標信息e.touches[ 0 ]
側滑菜單
側滑菜單是一個經典APP佈局方案,小程式中沒有提供這個組件,甚是遺憾。不過實現起來也不是很難,但是總感覺有點彆扭...
側滑菜單的樣式採用了固定定位的佈局position: fixed
,預設隱藏與左側,當點擊浮動按鈕時彈出,點擊遮罩或者側欄上邊的關閉按鈕時收回。側欄的彈出和收回動畫採用小程式提供的動畫API。
<view class="slide-mask" style="display:{{maskDisplay}}" bindtap="slideCloseEvent"></view>
<view class="slide-menu" style="right: {{slideRight}}px;width: {{slideWidth}}px;height:{{slideHeight}}px;" animation="{{slideAnimation}}">
<icon type="cancel" size="30" class="close-btn" color="#FFF" bindtap="slideCloseEvent" />
<scroll-view scroll-y="true" style="height:100%;width:100%">
<view class="header">
<view class="userinfo">
<image src="../../images/avatar.png" class="avatar"></image>
<text>Oopsguy</text>
</view>
<view class="toolbar">
<view class="item">
<image src="../../images/fav.png"></image>
<text>收藏</text>
</view>
<view class="item" bindtap="toSettingPage">
<image src="../../images/setting.png"></image>
<text>設置</text>
</view>
</view>
</view>
<view class="menu-item home">
<text>首頁</text>
</view>
<view class="slide-inner">
<block wx:for="{{themeData}}">
<view class="menu-item" data-id="{{item.id}}" bindtap="toThemePage">
<text>{{item.name}}</text>
<image src="../../images/plus.png"></image>
</view>
</block>
</view>
</scroll-view>
</view>
/*slide-menu*/
.slide-mask {
position: fixed;
width: 100%;
top: 0;
left: 0;
bottom: 0;
background: rgba(0, 0, 0, .3);
z-index: 800;
}
.slide-menu {
position: fixed;
top: 0;
background: #FFF;
z-index: 900;
}
/*.slide-menu .slide-inner {
padding: 40rpx;
}*/
.slide-menu .header {
background: #019DD6;
height: 200rpx;
color: #FFF;
padding: 20rpx 40rpx 0 40rpx;
}
.userinfo {
height: 80rpx;
line-height: 80rpx;
overflow: hidden;
}
.userinfo .avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 40rpx;
float: left;
}
.userinfo text {
float: left;
font-size: 35rpx;
}
.toolbar {
height: 100rpx;
padding-top: 25rpx;
line-height: 75rpx;
}
.toolbar .item {
width: 50%;
display: inline-block;
overflow: hidden;
text-align: center
}
.toolbar .item text {
display: inline-block;
font-size: 30rpx
}
.toolbar .item image {
display: inline-block;
position: relative;
top: 10rpx;
margin-right: 10rpx;
height: 50rpx;
width: 50rpx;
}
.slide-menu .menu-item {
position: relative;
height: 100rpx;
line-height: 100rpx;
padding: 0 40rpx;
font-size: 35rpx;
}
.slide-menu .menu-item:active {
background: #FAFAFA;
}
.slide-menu .menu-item image {
position: absolute;
top: 25rpx;
right: 40rpx;
width: 50rpx;
height: 50rpx;
}
.slide-menu .home {
color: #019DD6
}
.slide-menu .close-btn {
position: absolute;
top: 20rpx;
right: 40rpx;
z-index: 1000
}
以上是側欄的一個簡單的佈局和樣式,包含了側欄中的用戶信息塊和主題日報列表。當然這些信息是需要通過js的中網路請求來獲取的。側欄結構上邊有一個class為slide-mask
的view,這是一個遮罩元素,當側欄彈出的時候,側欄後邊就有一層輕微透明的黑色遮罩。側欄的高度和寬度初始是不定的,需要在進入頁面的時候,馬上獲取設備信息來獲取屏幕的高度寬度調整側欄樣式。
//獲取設備信息,屏幕的高度寬度
onLoad: function() {
var _this = this;
wx.getSystemInfo( {
success: function( res ) {
_this.setData( {
screenHeight: res.windowHeight,
screenWidth: res.windowWidth,
slideHeight: res.windowHeight,
slideRight: res.windowWidth,
slideWidth: res.windowWidth * 0.7
});
}
});
}
寬度我取了屏幕寬度的70%,高度一致。側欄的彈出收回動畫使用內置動畫API
//側欄展開
function slideUp() {
var animation = wx.createAnimation( {
duration: 600
});
this.setData( { maskDisplay: 'block' });
animation.translateX( '100%' ).step();
this.setData( {
slideAnimation: animation.export()
});
}
//側欄關閉
function slideDown() {
var animation = wx.createAnimation( {
duration: 800
});
animation.translateX( '-100%' ).step();
this.setData( {
slideAnimation: animation.export()
});
this.setData( { maskDisplay: 'none' });
}
側欄彈出的時候,遮罩的css屬性display
設置為block
顯示,側欄通過css動畫transform
來想右側移動了100%的寬度translateX(100%)
,側欄收回時,動畫恰好與彈出的相反,其實這些動畫最後都會翻譯為css3動畫屬性,這些API只是css3動畫的封裝。為了點擊遮罩收回側欄,遮罩的tap
事件也要綁定slideCloseEvent
//浮動球點擊 側欄展開
ballClickEvent: function() {
slideUp.call( this );
},
//遮罩點擊 側欄關閉
slideCloseEvent: function() {
slideDown.call( this );
}
日報詳情頁
內容
日報的內容也是最難做的,因為介面返回的內容是html...,天呀,是html!小程式肯本就不支持,解析html的過程非常痛苦,因為本人的正則表達式只是幾乎為0,解析方案的尋找過程很虐心,經典的jQuery是用不了了,又沒有dom
,無法用傳統的方式解析html。嘗試了正則學習,但是也是無法在短時間內掌握,尋找了很多解析庫,大多是依賴瀏覽器api。不過,上天是不會忽視有心人的,哈哈,還是被我找到瞭解決方案。幸運的我發現了一個用正則編寫的和類似與語法分析方法的xml解析庫。這個庫是一個very good的網友封裝的html解析庫。詳情點擊 用Javascript解析html。
由於日報詳情內容的html部分結構太大,這裡只列出了簡要的結構,這個結構是通用的(不過不保證知乎會變動結構,要是變動了,之前的解析可能就沒用了...心累)
<div class="question">
<h2 class="question-title">日本的六大財閥現在怎麼樣了?</h2>
<div class="answer">
<div class="meta">
![](http://upload-images.jianshu.io/upload_images/2192094-454caed2ef752b34.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
<span class="author">leon,</span><span class="bio">data analyst</span>
</div>
<div class="content">
<p>“財閥”在戰後統稱為 Group(集團),是以銀行和傳統工業企業為核心的鬆散集合體,由於歷史淵源而有相互持股。</p>
<p>Group 對於當今日本企業的意義在於:</p>
<p><strong>MUFG:三菱集團、三和集團(みどり會)</strong></p>
<p><img class="content-image" src="http://pic1.zhimg.com/70/90c319ac7a7b2723e5b511de954f45bc_b.jpg" alt=""
/></p>
</div>
</div>
<div class="view-more"><a href="http://www.zhihu.com/question/23907827">查看知乎討論<span class="js-question-holder"></span></a></div>
</div>
外層的.question
是日報中問題答案的顯示單位,可能有多個,因此需要迴圈顯示。.question-title
是問題的標題,.meta
中是作者的信息,img.avatar
是用戶的頭像,span.author
是用戶的名稱,span.bio
可能使用戶的簽名吧。最難解析的是.content
中的內容,比較多。但是有個規律就是都是以<p>
標簽包裹著,獲取了.content
中的所有p
就可以得到所有的段落。之後再解析出段落中的圖片。
以下是詳情頁的內容展示模版
<view style="padding-bottom: 150rpx;">
<block wx:for="{{news.body}}">
<view class="article">
<view class="title" wx:if="{{item.title && item.title != ''}}">
<text>{{item.title}}</text>
</view>
<view class="author-info" wx:if="{{(item.avatar && item.avatar != '') || (item.author && item.author != '') || (item.bio && item.bio != '')}}">
<image wx:if="{{item.avatar && item.avatar != ''}}" class="avatar" src="{{item.avatar}}"></image>
<text wx:if="{{item.author && item.author != ''}}" class="author-name">{{item.author}}</text>
<text wx:if="{{item.bio && item.bio != ''}}" class="author-mark">,{{item.bio}}</text>
</view>
<view class="content" wx:if="{{item.content && item.content.length > 0}}">
<block wx:for="{{item.content}}" wx:for-item="it">
<block wx:if="{{it.type == 'p'}}">
<text>{{it.value}}</text>
</block>
<block wx:elif="{{it.type == 'img'}}">
<image mode="aspectFill" src="{{it.value}}" data-src="{{it.value}}" bindtap="previewImgEvent" />
</block>
<block wx:elif="{{it.type == 'pstrong'}}">
<text class="strong">{{it.value}}</text>
</block>
<block wx:elif="{{it.type == 'pem'}}">
<text class="em">{{it.value}}</text>
</block>
<block wx:elif="{{it.type == 'blockquote'}}">
<text class="qoute">{{it.value}}</text>
</block>
<block wx:else>
<text>{{it.value}}</text>
</block>
</block>
</view>
<view class="discuss" wx:if="{{item.more && item.more != ''}}">
<navigator url="{{item.more}}">查看知乎討論</navigator>
</view>
</view>
</block>
</view>
可以看出模版中的內容展示部分用了蠻多的block加判斷語句wx:if wx:elif wx:else
。這些都是為了需要根據解析後的內容類型來判斷需要展示什麼標簽和樣式。解析後的內容大概格式是這樣的:
{
body: [
title: '標題',
author: '作者',
bio: '簽名',
avatar: '頭像',
more: '更多地址',
content: [ //內容
{
type: 'p',
value: '普通段落內容'
},
{
type: 'img',
value: 'http://xxx.xx.xx/1.jpg'
},
{
type: 'pem',
value: '...'
},
...
]
],
...
}
需要註意的一點是主題日報有時候返回的html內容是經過unicode編碼的不能直接顯示,裡邊全是類似&#xxxx;
的字元,這需要單獨為主題日報的日報詳情解析編碼。
再點擊主題日報中的列表項是,傳遞一個標記是主題日報的參數theme
//跳轉到日報詳情頁
toDetailPage: function( e ) {
var id = e.currentTarget.dataset.id;
wx.navigateTo( {
url: '../detail/detail?theme=1&id=' + id
});
},
然後在Detail.js的onLoad
事件中接受參數
//獲取列表殘過來的參數 id:日報id, theme:是否是主題日報內容(因為主題日報的內容有些需要單獨解析)
onLoad: function( options ) {
var id = options.id;
var isTheme = options[ 'theme' ];
this.setData( { id: id, isTheme: isTheme });
},
之後開始請求介面獲取日報詳情,並根據是否是主題日報進行個性化解析
//載入頁面相關數據
function loadData() {
var _this = this;
var id = this.data.id;
var isTheme = this.data.isTheme;
//獲取日報詳情內容
_this.setData( { loading: true });
requests.getNewsDetail( id, ( data ) => {
data.body = utils.parseStory( data.body, isTheme );
_this.setData( { news: data, pageShow: 'block' });
wx.setNavigationBarTitle( { title: data.title }); //設置標題
}, null, () => {
_this.setData( { loading: false });
});
}
以上傳入一個isTheme
參數進入解析方法,解析方法根據此參數判斷是否需要進行單獨的編碼解析。
內容解析的庫代碼比較多,就不貼出了,可以到git上查看。這裡給出解析的封裝。
var HtmlParser = require( 'htmlParseUtil.js' );
String.prototype.trim = function() {
return this.replace( /(^\s*)|(\s*$)/g, '' );
}
String.prototype.isEmpty = function() {
return this.trim() == '';
}
/**
* 快捷方法 獲取HtmlParser對象
* @param {string} html html文本
* @return {object} HtmlParser
*/
function $( html ) {
return new HtmlParser( html );
}
/**
* 解析story對象的body部分
* @param {string} html body的html文本
* @param {boolean} isDecode 是否需要unicode解析
* @return {object} 解析後的對象
*/
function parseStory( html, isDecode ) {
var questionArr = $( html ).tag( 'div' ).attr( 'class', 'question' ).match();
var stories = [];
var $story;
if( questionArr ) {
for( var i = 0, len = questionArr.length;i < len;i++ ) {
$story = $( questionArr[ i ] );
stories.push( {
title: getArrayContent( $story.tag( 'h2' ).attr( 'class', 'question-title' ).match() ),
avatar: getArrayContent( getArrayContent( $story.tag( 'div' ).attr( 'class', 'meta' ).match() ).jhe_ma( 'img', 'src' ) ),
author: getArrayContent( $story.tag( 'span' ).attr( 'class', 'author' ).match() ),
bio: getArrayContent( $story.tag( 'span' ).attr( 'class', 'bio' ).match() ),
content: parseStoryContent( $story, isDecode )