React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

code_mcx發表於2019-03-01

專案打包指令碼配置針對生產環境已經做了修改,增加了對樣式的壓縮,把樣式統一打包到樣式檔案中。詳細請看第一節配置Stylus預處理語言。本節所有內容緊接上一節,上一節地址:juejin.im/post/5a3a6c…

上一節開發了推薦頁面,這一節實現專輯頁面開發、進入動畫和圖片拉伸動畫。話不多說,先看效果圖

React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

頭部是一個很常見的標題加一個返回按鈕(標準app的做法~~~),上部分是專輯背景圖片,圖片下面就是專輯的歌曲列表,最底下就是專輯的簡介

資料抓取

開啟chrome瀏覽器,位址列輸入QQ音樂官網:y.qq.com。開啟後點選專輯

React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

點選後,如下圖

React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

開啟開發者工具(按F12或CTRL+SHIFT+I),然後任意選一張專輯點選

React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

這個時候回彈出一個新的視窗,直接關閉它。回到剛才的開發者工具,可以看到有一個請求,這個請求就是獲取專輯詳情的

React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

點開preview,這裡面就是我們需要的資料

React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

接下來編寫獲取介面的程式碼

api目錄下的config.js中,新增專輯詳情的url配置

config.js

const URL = {
    /*推薦輪播*/
    carousel: "https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg",
    /*最新專輯*/
    newalbum: "https://u.y.qq.com/cgi-bin/musicu.fcg",
    /*專輯資訊*/
    albumInfo: "https://c.y.qq.com/v8/fcg-bin/fcg_v8_album_info_cp.fcg"
};
複製程式碼

api下的recommend.js中新增獲取專輯請求的方法

recommend.js

export function getAlbumInfo(albumMid) {
	const data = Object.assign({}, PARAM, {
		albummid: albumMid,
		g_tk: 1278911659,
		hostUin: 0,
		platform: "yqq",
		needNewCode: 0
	});
	return jsonp(URL.albumInfo, data, OPTION);
}
複製程式碼

推薦頁子路由

為了進入專輯詳情頁面,需要在推薦頁面中實現點選專輯項跳轉到專輯詳情的路由。先建立專輯頁面元件Album.js 在src下的components下面新建album資料夾,然後在album下面新建Album.jsalbum.styl

Album.js

import React from "react"

import "./album.styl"

class Album extends React.Component {
    constructor(props) {
        super(props);
    }
    
    componentDidMount() {
    }
    
    render() {
        return (
            <div className="music-album">
                Album
            </div>
        );
    }
}

export default Album
複製程式碼

album.styl

.music-album
  position: fixed
  top: 0
  left: 0
  right: 0
  bottom: 0
  background-color: #212121
  z-index: 100
複製程式碼

點開Recommend.js(src下面components中的recommend目錄下面)。匯入RouteAlbum.js

Recommend.js

import {Route} from "react-router-dom"
import Album from "../album/Album"
複製程式碼

render方法第一行增加

let {match} = this.props;
複製程式碼

match是路由通過props傳遞給元件的包含了url、引數等相關資訊。然後在根元素下面新增子路由

<div className="music-recommend">
    <Scroll refresh={this.state.refreshScroll}
            onScroll={(e) => {
                /*檢查懶載入元件是否出現在檢視中,如果出現就載入元件*/
                forceCheck();}}>
        ...
        
    </Scroll>
    <Loading title="正在載入..." show={this.state.loading}/>
    <Route path={`${match.url + '/:id'}`} component={Album} />
</div>
複製程式碼

最後給每一個專輯包裹元素新增點選事件

let albums = this.state.newAlbums.map(item => {
//通過函式建立專輯物件
let album = AlbumModel.createAlbumByList(item);
return (
    <div className="album-wrapper" key={album.mId}
         onClick={this.toAlbumDetail(`${match.url + '/' + album.mId}`)}>
        ...
    </div>
);
});
複製程式碼
toAlbumDetail(url) {
    /*scroll元件會派發一個點選事件,不能使用連結跳轉*/
    return () => {
        this.props.history.push({
            pathname: url
        });
    }
}
複製程式碼

這裡使用react路由提供的history物件來實現程式設計路由跳轉,使用閉包函式把每次迴圈傳入的url作為區域性變數。這樣每次點選item獲取到的都是對應傳遞的url

Header元件封裝

在整個專案中標題是很常見的,這裡把頭部標題和返回按鈕封裝成一個公用的Header元件。Header元件接受一個title標題,返回按鈕點選的時候具有返回的功能,其實就是路由的返回,這裡在Header元件內部處理這個點選事件 在src下面的common目錄下新建header資料夾,在header資料夾下面新建Header.jsheader.styl

Header.js

import React from "react"
import "./header.styl"

class MusicHeader extends React.Component {
    handleClick() {
        window.history.back();
    }
    render() {
        return (
            <div className="music-header">
	            <span className="header-back" onClick={this.handleClick}>
	                <i className="icon-back"></i>
	            </span>
                <div className="header-title">
                    {this.props.title}
                </div>
            </div>
        );
    }
}

export default MusicHeader
複製程式碼

上訴程式碼的handleClick函式中也可以使用history.goBack()來實現路由的回退。返回按鈕使用的是一個字型圖示,在App.js中引入字型圖示樣式,作為全域性引入,這樣所有的元件都可以使用字型圖示樣式

import "../assets/stylus/font.styl"
複製程式碼

header.styl

.music-header
  position: fixed
  width: 100%
  height: 55px
  line-height: 55px
  color: #FFFFFF
  text-align: center
  font-size: 18px
  .header-back
    position: absolute
    left: 10px
    font-size: 22px
  .header-title
    margin: 0 40px
    overflow: hidden
    text-overflow: ellipsis
    white-space: nowrap
複製程式碼

專輯頁開發

在上一節已經為專輯資料建立了一類模型,對於專輯詳情介面只需要一個建立物件的函式即可。在src下面的model目錄中的album.js中新增以下程式碼


/**
 *  通過專輯詳情資料建立專輯物件函式
 */
export function createAlbumByDetail(data) {
    return new Album(
        data.id,
        data.mid,
        data.name,
        `http://y.gtimg.cn/music/photo_new/T002R300x300M000${data.mid}.jpg?max_age=2592000`,
        data.singername,
        data.aDate
    );
}
複製程式碼

專輯列表中有很多歌曲資料,這裡為歌曲資料建立一個Song類,方便後續使用。同樣在src下的model中新建song.js,編寫一個建立Song類物件的函式

song.js

/**
 *  歌曲類模型
 */
export class Song {
	constructor(id, mId, name, img, duration, url, singer) {
		this.id = id;
		this.mId = mId;
		this.name = name;
		this.img = img;
		this.duration = duration;
		this.url = url;
		this.singer = singer;
	}
}

/**
 *  建立歌曲物件函式
 */
export function createSong(data) {
	return new Song(
		data.songid,
		data.songmid,
		data.songname,
		`http://y.gtimg.cn/music/photo_new/T002R300x300M000${data.albummid}.jpg?max_age=2592000`,
		data.interval,
		"",
		filterSinger(data.singer)
	);
}

function filterSinger(singers) {
	let singerArray = singers.map(singer => {
		return singer.name;
	});
	return singerArray.join("/");
}
複製程式碼

專輯頁中需要用到上一節封裝的Scroll元件和Loading元件以及封裝的Header元件。回到Album.js中匯入這個三個元件

import Header from "@/common/header/Header"
import Scroll from "@/common/scroll/Scroll"
import Loading from "@/common/loading/Loading"
複製程式碼

匯入專輯請求函式,介面成功狀態碼常量,專輯和歌曲模型類

import {getAlbumInfo} from "@/api/recommend"
import {CODE_SUCCESS} from "@/api/config"
import * as AlbumModel from "@/model/album"
import * as SongModel from "@/model/song"
複製程式碼

Album.js主要程式碼如下

class Album extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            loading: true,
            album: {},
            songs: [],
            refreshScroll: false
        }
    }
    componentDidMount() {
        let albumBgDOM = ReactDOM.findDOMNode(this.refs.albumBg);
        let albumContainerDOM = ReactDOM.findDOMNode(this.refs.albumContainer);
        albumContainerDOM.style.top = albumBgDOM.offsetHeight + "px";

        getAlbumInfo(this.props.match.params.id).then((res) => {
            console.log("獲取專輯詳情:");
            if (res) {
                console.log(res);
                if (res.code === CODE_SUCCESS) {
                    let album = AlbumModel.createAlbumByDetail(res.data);
                    album.desc = res.data.desc;

                    let songList = res.data.list;
                    let songs = [];
                    songList.forEach(item => {
                        let song = SongModel.createSong(item);
                        songs.push(song);
                    });
                    this.setState({
                        loading: false,
                        album: album,
                        songs: songs
                    }, () => {
                        //重新整理scroll
                        this.setState({refreshScroll:true});
                    });
                }
            }
        });
    }

    render() {
        let album = this.state.album;
        let songs = this.state.songs.map((song) => {
            return (
                <div className="song" key={song.id}>
                    <div className="song-name">{song.name}</div>
                    <div className="song-singer">{song.singer}</div>
                </div>
            );
        });
        return (
            <div className="music-album">
                <Header title={album.name} ref="header"></Header>
                <div style={{position:"relative"}}>
                    <div ref="albumBg" className="album-img" style={{backgroundImage: `url(${album.img})`}}>
                        <div className="filter"></div>
                    </div>
                    <div ref="albumFixedBg" className="album-img fixed" style={{backgroundImage: `url(${album.img})`}}>
                        <div className="filter"></div>
                    </div>
                    <div className="play-wrapper" ref="playButtonWrapper">
                        <div className="play-button">
                            <i className="icon-play"></i>
                            <span>播放全部</span>
                        </div>
                    </div>
                </div>
                <div ref="albumContainer" className="album-container">
                    <div className="album-scroll" style={this.state.loading === true ? {display:"none"} : {}}>
                        <Scroll refresh={this.state.refreshScroll}>
                            <div className="album-wrapper">
                                <div className="song-count">專輯 共{songs.length}首</div>
                                <div className="song-list">
                                    {songs}
                                </div>
                                <div className="album-info" style={album.desc? {} : {display:"none"}}>
                                    <h1 className="album-title">專輯簡介</h1>
                                    <div className="album-desc">
                                        {album.desc}
                                    </div>
                                </div>
                            </div>
                        </Scroll>
                    </div>
                    <Loading title="正在載入..." show={this.state.loading}/>
                </div>
            </div>
        );
    }
}
複製程式碼

上訴程式碼在componentDidMount中通過match.params.id獲取引數id,再傳送請求獲取到資料後先建立Album物件再建立Song列表,然後呼叫setState更新ui。此時歌曲還缺少檔案地址,歌曲檔案地址介面獲取見juejin.im/post/5a3522…

在api目錄下的config中新增歌曲vkey地址,然後新建song.js,編寫用來獲取歌曲vkey請求

config.js

/*歌曲vkey*/
songVkey: "https://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg"
複製程式碼

song.js

import jsonp from "./jsonp"
import {URL, PARAM} from "./config"

export function getSongVKey(songMid) {
	const data = Object.assign({}, PARAM, {
		g_tk: 1278911659,
		hostUin: 0,
		platform: "yqq",
		needNewCode: 0,
		cid: 205361747,
		uin: 0,
		songmid: songMid,
		filename: `C400${songMid}.m4a`,
		guid: 3655047200
	});
	const option = {
		param: "callback",
		prefix: "callback"
	};
	return jsonp(URL.songVkey, data, option);
}
複製程式碼

Album.js中匯入上述方法

import {getSongVKey} from "@/api/song"
複製程式碼

編寫一個獲取歌曲vkey的方法

getSongUrl(song, mId) {
    getSongVKey(mId).then((res) => {
        if (res) {
            if(res.code === CODE_SUCCESS) {
                if(res.data.items) {
                    let item = res.data.items[0];
                    song.url =  `http://dl.stream.qqmusic.qq.com/${item.filename}?vkey=${item.vkey}&guid=3655047200&fromtag=66`
                }
            }
        }
    });
}
複製程式碼

對歌曲進行遍歷的時候呼叫getSongUrl獲取歌曲檔案地址

songList.forEach(item => {
    let song = SongModel.createSong(item);
    //獲取歌曲vkey
    this.getSongUrl(song, item.songmid);
    songs.push(song);
});
複製程式碼

song是一個物件,物件是引用型別,把song傳遞給getSongUrl的第一個引數,他們指向的是同一塊記憶體,也就是說他們是同一個的例項,那麼他們的url屬性也是一樣的。在getSongUrl中修改了url也就修改了傳遞進去的song物件的url屬性

實現動畫

  1. 專輯頁進入動畫

在很多app中頁面進入時都會有平移動畫,這樣看起來介面跳轉不會顯得很生硬。這裡使用react-transition-group動畫庫來實現動畫。

注意:這裡使用的是2.x版本,1.x和2.x版本api相差很大。詳細請看github

react-transition-group提供了三個元件

  1. Transition(過渡動畫元件。允許從一種狀態到另一種狀態的改變。預設跟蹤元件的進入和離開狀態)
  2. TransitionGroup(管理Transition元件集合)
  3. CSSTransition(使用css過渡和動畫的Transition元件)

詳細用法請看:reactcommunity.org/react-trans…

這裡使用CSSTransition元件來做動畫。先安裝react-transition-group

npm install react-transition-group --save
複製程式碼

在Album.js中匯入CSSTransition

import {CSSTransition} from "react-transition-group"
複製程式碼

給Album元件新增一個show屬性用來控制動畫的狀態

this.state = {
    show: false,
    loading: true,
    album: {},
    songs: [],
    refreshScroll: false
}
複製程式碼

使用CSSTransition元件包裹Album元件的根元素

<CSSTransition in={this.state.show} timeout={300} classNames="translate">
    <div className="music-album">
        ...
    </div>
</CSSTransition>
複製程式碼

CSSTransition接受intimeoutclassNames三個props。其中in控制元件的狀態。當in為true時,元件的子元素會應用translate-entertranslate-enter-active樣式,當in為false時,元件的子元素會應用translate-exittranslate-exit-active樣式。timeout指定過渡時間

這裡只實現元件進入動畫,元件離開動畫可通過Header元件的返回按鈕點選事件結合動畫鉤子函式實現

這個動畫使用的樣式會被用在多處,我們把樣式寫在App.styl

.translate-enter
  transform: translate3d(100%, 0, 0)
  &.translate-enter-active
    transition: transform .3s
    transform: translate3d(0, 0, 0)
複製程式碼

在元件掛載完成後,也就是componentDidMount函式中將show設定為true,讓元件應用translate-entertranslate-enter-active樣式從而實現過渡動畫

componentDidMount() {
    this.setState({
        show: true
    });
    ...
}
複製程式碼
  1. 列表滾動和圖片拉伸效果

先來看gif圖

React全家桶構建一款Web音樂App實戰(四):專輯頁開發及其動畫實現

上圖中列表往上滾動會覆蓋圖片,當超過頭部高度的時候會隱藏,圖片上半部分Header高度的區域顯示在列表上方。向下拉伸時圖片會跟著放大。向上滾動效果主要利用元素的position定位,和z-index設定層級關係,圖片這裡做了兩個同樣的元素,它們都相對父元素進行定位,一個是用來預設顯示,另外一個隱藏並且高度只有Header高,隱藏的元素層級比列表要高。當裡列表往上滾動超過Header的底部時就顯示隱藏的圖片。向下滾動利用監聽Scroll元件的滾動事件,根據滾動的高度給圖片設定對應的scale值,同時給按鈕設定margin-top

先給滾動列表設定溢位隱藏,覆蓋Scroll元件的樣式

.scroll-view
    overflow: visible
複製程式碼

監聽Scroll元件的滾動,判斷y是否小於0,小於0表示向上滾動。當滾動y值的絕對值加上Header的高度大於圖片高度的時候此時已經超過了Header的底部,這個時候顯示隱藏的圖片,向下滾動沒有達到Header的底部時隱藏圖片(這裡使用了兩張圖片,其實也可以使用一張圖片,當滾動到Header元件的底部的時候設定圖片的高度和z-index即可)

/**
 * 監聽scroll
 */
scroll = ({y}) => {
    let albumBgDOM = ReactDOM.findDOMNode(this.refs.albumBg);
    let albumFixedBgDOM = ReactDOM.findDOMNode(this.refs.albumFixedBg);
    if (y < 0) {
        if (Math.abs(y) + 55 > albumBgDOM.offsetHeight) {
            albumFixedBgDOM.style.display = "block";
        } else {
            albumFixedBgDOM.style.display = "none";
        }
    }
}
複製程式碼
<Scroll refresh={this.state.refreshScroll} onScroll={this.scroll}>
    ...
</Scroll>
複製程式碼

接下來處理圖片拉伸,在if (y < 0)增加else塊,當y大於0時表示向下滾動

scroll = ({y}) => {
    let albumBgDOM = ReactDOM.findDOMNode(this.refs.albumBg);
    let albumFixedBgDOM = ReactDOM.findDOMNode(this.refs.albumFixedBg);
    let playButtonWrapperDOM = ReactDOM.findDOMNode(this.refs.playButtonWrapper);
    if (y < 0) {
        if (Math.abs(y) + 55 > albumBgDOM.offsetHeight) {
            albumFixedBgDOM.style.display = "block";
        } else {
            albumFixedBgDOM.style.display = "none";
        }
    } else {
        let transform = `scale(${1 + y * 0.004}, ${1 + y * 0.004})`;
        albumBgDOM.style["webkitTransform"] = transform;
        albumBgDOM.style["transform"] = transform;
        playButtonWrapperDOM.style.marginTop = `${y}px`;
    }
}
複製程式碼

album.styl完整程式碼見結尾原始碼地址

總結

這一節使用history物件來實現程式設計子路由跳轉,簡單的介紹了react-transition-group做過渡動畫,後面會介紹使用react-transition-group結合鉤子函式實現動畫效果。還利用上一節封裝的Scroll元件的滾動事件實現了列表滾動和圖片拉伸效果,主要是明白如何通過滾動的y值判斷是上拉還是下拉、圖片的佈局設計以及如何判斷列表滾動到了Header元件底部

完整專案地址:github.com/code-mcx/ma…

本章節程式碼在chapter4分支

後續更新中...

相關文章