阅读 2010

React全家桶构建一款Web音乐App实战(四):专辑页开发及其动画实现

项目打包脚本配置针对生产环境已经做了修改,增加了对样式的压缩,把样式统一打包到样式文件中。详细请看第一节配置Stylus预处理语言。本节所有内容紧接上一节,上一节地址:juejin.im/post/5a3a6c…

上一节开发了推荐页面,这一节实现专辑页面开发、进入动画和图片拉伸动画。话不多说,先看效果图

头部是一个很常见的标题加一个返回按钮(标准app的做法~~~),上部分是专辑背景图片,图片下面就是专辑的歌曲列表,最底下就是专辑的简介

数据抓取

打开chrome浏览器,地址栏输入QQ音乐官网:y.qq.com。打开后点击专辑

点击后,如下图

打开开发者工具(按F12或CTRL+SHIFT+I),然后任意选一张专辑点击

这个时候回弹出一个新的窗口,直接关闭它。回到刚才的开发者工具,可以看到有一个请求,这个请求就是获取专辑详情的

点开preview,这里面就是我们需要的数据

接下来编写获取接口的代码

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图

上图中列表往上滚动会覆盖图片,当超过头部高度的时候会隐藏,图片上半部分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分支

后续更新中...

关注下面的标签,发现更多相似文章
评论