本节代码对应 GitHub 分支: chapter9
# 骨架搭建
首先完成播放列表的轮廓,以及将它和播放器进行对接。
import React from 'react';
import {connect} from "react-redux";
import { PlayListWrapper, ScrollWrapper } from './style';
function PlayList (props) {
return (
<PlayListWrapper>
<div className="list_wrapper">
<ScrollWrapper></ScrollWrapper>
</div>
</PlayListWrapper>
)
}
export default PlayList;
相应的 style.js 中:
import styled from'styled-components';
import style from '../../../assets/global-style';
export const PlayListWrapper = styled.div `
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 1000;
background-color: ${style ["background-color-shadow"]};
.list_wrapper {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
opacity: 1;
border-radius: 10px 10px 0 0;
background-color: ${style ["highlight-background-color"]};
transform: translate3d (0, 0, 0);
.list_close {
text-align: center;
line-height: 50px;
background: ${style ["background-color"]};
font-size: ${style ["font-size-l"]};
color: ${style ["font-color-desc"]};
}
}
`;
export const ScrollWrapper = styled.div`
height: 400px;
overflow: hidden;
`;
现在你可以看到弹出的一个白色浮层了,这就是播放列表组件。现在我们将它和播放器做一下对接。
首先,需要在 Player/index.js 中,往 miniPlayer 和 normalPlayer 子组件中分别传入这个属性:
// 当然先要从 props 取出 togglePlayListDispatch,这部分大家自己加上即可
togglePlayList={togglePlayListDispatch}
然后在 miniPlayer/index.js 中,增加以下逻辑:
// 取出
const { togglePlayList } = props;
const handleTogglePlayList = (e) => {
togglePlayList (true);
e.stopPropagation ();
};
// 给列表图标绑定事件
<div className="control" onClick={handleTogglePlayList}>
同时,在 normalPlayer/index.js 中,增加:
const { togglePlayList } = props;
//...
<div
className="icon i-right"
onClick={() => togglePlayList (true)}
>
现在我们让 PlayList 组件对接上 redux 中的数据。
import { connect } from "react-redux";
// 组件代码省略
// 映射 Redux 全局的 state 到组件的 props 上
const mapStateToProps = (state) => ({
showPlayList: state.getIn (['player', 'showPlayList']),
});
// 映射 dispatch 到 props 上
const mapDispatchToProps = (dispatch) => {
return {
togglePlayListDispatch (data) {
dispatch (changeShowPlayList (data));
}
}
};
// 将 ui 组件包装成容器组件
export default connect (mapStateToProps, mapDispatchToProps)(React.memo (PlayList));
连接后我们专心来写组件内部的逻辑。
// 即将引入的模块
import { connect } from "react-redux";
import { PlayListWrapper, ScrollWrapper, ListHeader, ListContent } from './style';
import { CSSTransition } from 'react-transition-group';
import React, { useRef, useState, useCallback } from 'react';
import { prefixStyle, getName } from './../../../api/utils';
import { changeShowPlayList, changeCurrentIndex, changePlayMode, changePlayList } from "../store/actionCreators";
import { playMode } from "../../../api/config";
import Scroll from '../../../baseUI/scroll';
// 组件内代码
function PlayList (props) {
const { showPlayList } = props;
const { togglePlayListDispatch } = props;
const playListRef = useRef ();
const listWrapperRef = useRef ();
const isShow = useState (false);
return (
<CSSTransition
in={showPlayList}
timeout={300}
classNames="list-fade"
onEnter={onEnterCB}
onEntering={onEnteringCB}
onExiting={onExitingCB}
onExited={onExitedCB}
>
<PlayListWrapper
ref={playListRef}
style={isShow === true ? { display: "block" } : { display: "none" }}
onClick={() => togglePlayListDispatch (false)}
>
<div className="list_wrapper" ref={listWrapperRef} >
<ScrollWrapper></ScrollWrapper>
</div>
</PlayListWrapper>
</CSSTransition>
)
}
接下来编写动画钩子里面的回调函数:
import { prefixStyle } from './../../../api/utils';
const transform = prefixStyle ("transform");
const onEnterCB = useCallback (() => {
// 让列表显示
setIsShow (true);
// 最开始是隐藏在下面
listWrapperRef.current.style [transform] = `translate3d (0, 100%, 0)`;
}, [transform]);
const onEnteringCB = useCallback (() => {
// 让列表展现
listWrapperRef.current.style ["transition"] = "all 0.3s";
listWrapperRef.current.style [transform] = `translate3d (0, 0, 0)`;
}, [transform]);
const onExitingCB = useCallback (() => {
listWrapperRef.current.style ["transition"] = "all 0.3s";
listWrapperRef.current.style [transform] = `translate3d (0px, 100%, 0px)`;
}, [transform]);
const onExitedCB = useCallback (() => {
setIsShow (false);
listWrapperRef.current.style [transform] = `translate3d (0px, 100%, 0px)`;
}, [transform]);
在 style.js 中增加动画部分:
export const PlayListWrapper = styled.div `
/* 下面是动画部分的代码 */
&.list-fade-enter {
opacity: 0;
}
&.list-fade-enter-active {
opacity: 1;
transition: all 0.3s;
}
&.list-fade-exit {
opacity: 1;
}
&.list-fade-exit-active {
opacity: 0;
transition: all 0.3s;
}
`
现在大家点击列表图标便能弹出浮层了。
# 完成列表展示功能
现在我们来往浮层中增添列表的内容和功能。
首先,得从 redux 中拿到相应的数据。获取数据如下:
// 映射 Redux 全局的 state 到组件的 props 上
const mapStateToProps = (state) => ({
currentIndex: state.getIn (['player', 'currentIndex']),
currentSong: state.getIn (['player', 'currentSong']),
playList: state.getIn (['player', 'playList']),// 播放列表
sequencePlayList: state.getIn (['player', 'sequencePlayList']),// 顺序排列时的播放列表
showPlayList: state.getIn (['player', 'showPlayList']),
mode: state.getIn (['player', 'mode'])
});
// 映射 dispatch 到 props 上
const mapDispatchToProps = (dispatch) => {
return {
togglePlayListDispatch (data) {
dispatch (changeShowPlayList (data));
},
// 修改当前歌曲在列表中的 index,也就是切歌
changeCurrentIndexDispatch (data) {
dispatch (changeCurrentIndex (data));
},
// 修改当前的播放模式
changeModeDispatch (data) {
dispatch (changePlayMode (data));
},
// 修改当前的歌曲列表
changePlayListDispatch (data) {
dispatch (changePlayList (data));
},
}
};
从 props 中导入:
const {
currentIndex,
currentSong:immutableCurrentSong,
showPlayList,
playList:immutablePlayList,
mode,
sequencePlayList:immutableSequencePlayList
} = props;
const {
togglePlayListDispatch,
changeCurrentIndexDispatch,
changePlayListDispatch,
changeModeDispatch,
} = props;
const currentSong = immutableCurrentSong.toJS ();
const playList = immutablePlayList.toJS ();
const sequencePlayList = immutableSequencePlayList.toJS ();
然后让列表组件对接这些数据,渲染出整个列表。JSX 结构如下:
//div.list_wrapper 标签中包裹下面的结构
<ListHeader>
<h1 className="title">
{ getPlayMode () }
<span className="iconfont clear" onClick={handleShowClear}></span>
</h1>
</ListHeader>
<ScrollWrapper>
<Scroll >
<ListContent>
{
playList.map ((item, index) => {
return (
<li className="item" key={item.id}>
{getCurrentIcon (item)}
<span className="text">{item.name} - {getName (item.ar)}</span>
<span className="like">
<i className="iconfont"></i>
</span>
<span className="delete">
<i className="iconfont"></i>
</span>
</li>
)
})
}
</ListContent>
</Scroll>
</ScrollWrapper>
其中有一些 UI 相关的逻辑封装,包括 getPlayMode、getPlayMode 和 changeMode,比较直观,没有参杂太多的业务逻辑,直接贴出代码:
const getCurrentIcon = (item) => {
// 是不是当前正在播放的歌曲
const current = currentSong.id === item.id;
const className = current ? 'icon-play' : '';
const content = current ? '': '';
return (
<i className={`current iconfont ${className}`} dangerouslySetInnerHTML={{__html:content}}></i>
)
};
const getPlayMode = () => {
let content, text;
if (mode === playMode.sequence) {
content = "";
text = "顺序播放";
} else if (mode === playMode.loop) {
content = "";
text = "单曲循环";
} else {
content = "";
text = "随机播放";
}
return (
<div>
<i className="iconfont" onClick={(e) => changeMode (e)} dangerouslySetInnerHTML={{__html: content}}></i>
<span className="text" onClick={(e) => changeMode (e)}>{text}</span>
</div>
)
};
const changeMode = (e) => {
let newMode = (mode + 1) % 3;
// 具体逻辑比较复杂 后面来实现
};
当然,还有对应的 style.js 中的样式组件,首先是 ListHead , 作为列表头部包裹播放模式和清空按钮的容器组件:
export const ListHeader = styled.div `
position: relative;
padding: 20px 30px 10px 20px;
.title {
display: flex;
align-items: center;
>div {
flex:1;
.text {
flex: 1;
font-size: ${style ["font-size-m"]};
color: ${style ["font-color-desc"]};
}
}
.iconfont {
margin-right: 10px;
font-size: ${style ["font-size-ll"]};
color: ${style ["theme-color"]};
}
.clear {
${style.extendClick ()}
font-size: ${style ["font-size-l"]};
}
}
`
ListContent 组件用来包裹整个歌曲的列表,是一个列表包裹组件, 样式代码如下:
export const ListContent = styled.div `
.item {
display: flex;
align-items: center;
height: 40px;
padding: 0 30px 0 20px;
overflow: hidden;
.current {
flex: 0 0 20px;
width: 20px;
font-size: ${style ["font-size-s"]};
color: ${style ["theme-color"]};
}
.text {
flex: 1;
${style.noWrap ()}
font-size: ${style ["font-size-m"]};
color: ${style ["font-color-desc-v2"]};
.icon-favorite {
color: ${style ["theme-color"]};
}
}
.like {
${style.extendClick ()}
margin-right: 15px;
font-size: ${style ["font-size-m"]};
color: ${style ["theme-color"]};
}
.delete {
${style.extendClick ()}
font-size: ${style ["font-size-s"]};
color: ${style ["theme-color"]};
}
}
`
现在列表的展示已经成功完成!接下来就是处理对应的业务逻辑了,梳理一下,分别是点击切歌、删除歌曲和切换播放模式这三大功能。
# 点击切歌实现
首先,我们需要绑定对应的事件:
const handleChangeCurrentIndex = (index) => {
if (currentIndex === index) return;
changeCurrentIndexDispatch (index);
}
// 绑定点击事件
<li className="item" key={item.id} onClick={() => handleChangeCurrentIndex (index)}>
你现在点击一下歌曲,好像可以切歌,但是你发现有一个问题:
当你点击之后列表突然被隐藏了。这个 bug 是怎么产生的呢?其实我们之前在 PlayWrapper 绑定了这样一个事件:
onClick={() => togglePlayListDispatch (false)}
其实这是为了在用户点击列表外部的时候,直接将列表隐藏掉,也符合常理。但是 PlayWrapper 的范围是整个屏幕,包含了列表内容,因此出现了这个 bug。
如何解决这个问题?
且看这样一行代码:
<div className="list_wrapper" ref={listWrapperRef} onClick={e => e.stopPropagation ()}>
在 list_wrapper 中绑定点击事件,阻止它冒泡就行了。因为这个 div 包裹的就是整个歌曲的列表。
OK!接下来,我们来实现删除歌曲的功能,这里面又包括删除一首歌曲和清空全部歌曲。
# 删除一首歌曲
import { deleteSong } from "../store/actionCreators";
const { deleteSongDispatch } = props;
const handleDeleteSong = (e, song) => {
e.stopPropagation ();
deleteSongDispatch (song);
};
<span className="delete" onClick={(e) => handleDeleteSong (e, item)}>
<i className="iconfont"></i>
</span>
重点在于 deleteSongDispatch 的逻辑,我们来一步步拆解它。
//mapDispatchToProps 中
deleteSongDispatch (data) {
dispatch (deleteSong (data));
}
然后在 Player/store/constants.js 中增加:
export const DELETE_SONG = 'player/DELETE_SONG';
在 store/actionCreator.js 中导入 DELETE_SONG, 然后增加一个新的 action:
export const deleteSong = (data) => ({
type: DELETE_SONG,
data
});
现在转到 store/reducer.js 下编写删除的逻辑:
import { findIndex } from '../../../api/utils';// 注意引入工具方法
//...
const handleDeleteSong = (state, song) => {
// 也可用 loadsh 库的 deepClone 方法。这里深拷贝是基于纯函数的考虑,不对参数 state 做修改
const playList = JSON.parse (JSON.stringify (state.get ('playList').toJS ()));
const sequenceList = JSON.parse (JSON.stringify (state.get ('sequencePlayList').toJS ()));
let currentIndex = state.get ('currentIndex');
// 找对应歌曲在播放列表中的索引
const fpIndex = findIndex (song, playList);、
// 在播放列表中将其删除
playList.splice (fpIndex, 1);
// 如果删除的歌曲排在当前播放歌曲前面,那么 currentIndex--,让当前的歌正常播放
if (fpIndex < currentIndex) currentIndex--;
// 在 sequenceList 中直接删除歌曲即可
const fsIndex = findIndex (song, sequenceList);
sequenceList.splice (fsIndex, 1);
return state.merge ({
'playList': fromJS (playList),
'sequencePlayList': fromJS (sequenceList),
'currentIndex': fromJS (currentIndex),
});
}
export default (state = defaultState, action) => {
switch (action.type) {
//...
case actionTypes.DELETE_SONG:
return handleDeleteSong (state, action.data);
default:
return state;
}
}
现在点击单个歌曲后面的删除按钮便能成功地将歌曲从列表删除啦!
# 清空歌曲功能
一般而言,删除全部是一个影响比较大的操作,如果弹出一个确定框,让用户点击确定再操作,无疑是更加合理的。
因此,我们首先来封装弹框组件,然后进行事件绑定。
在 baseUI 目录下新建 confirm 文件夹,然后新建 index.js 文件。
其代码从 代码地址 (opens new window) 中获取,也是一个非常基础的组件,里面的封装操作和之前的类似,就不再浪费篇幅了。
回到 PlayList 组件,我们引入 Confirm 组件:
import Confirm from './../../../baseUI/confirm/index';
const confirmRef = useRef ();
//JSX
return (
<PlayListWrapper>
//...
<Confirm
ref={confirmRef}
text={"是否删除全部?"}
cancelBtnText={"取消"}
confirmBtnText={"确定"}
handleConfirm={handleConfirmClear}
/>
</PlayListWrapper>
)
现在来绑定一下清空事件:
const handleShowClear = () => {
confirmRef.current.show ();
}
<span className="iconfont clear" onClick={handleShowClear}></span>
现在的工作是编写 Confirm 组件的回调函数 handleConfirmClear。
import { changeSequecePlayList, changeCurrentSong, changePlayingState } from '../store/actionCreators';
//...
const { clearDispatch } = props;
const handleConfirmClear = () => {
clearDispatch ();
}
clearDispatch 在 mapDispatchToProps 中定义:
const mapDispatchToProps = (dispatch) => {
return {
//...
clearDispatch () {
// 1. 清空两个列表
dispatch (changePlayList ([]));
dispatch (changeSequecePlayList ([]));
// 2. 初始 currentIndex
dispatch (changeCurrentIndex (-1));
// 3. 关闭 PlayList 的显示
dispatch (changeShowPlayList (false));
// 4. 将当前歌曲置空
dispatch (changeCurrentSong ({}));
// 5. 重置播放状态
dispatch (changePlayingState (false));
}
}
};
# 修改播放模式
直接复用当时完成 normalPlayer 时修改播放模式的代码,当时我们实现过,估计你已经不陌生了。
// 从 utils.js 中再引入 shuffle 和 findIndex
import { prefixStyle, getName, shuffle, findIndex } from './../../../api/utils';
const changeMode = () => {
let newMode = (mode + 1) % 3;
if (newMode === 0) {
// 顺序模式
changePlayListDispatch (sequencePlayList);
let index = findIndex (currentSong, sequencePlayList);
changeCurrentIndexDispatch (index);
} else if (newMode === 1) {
// 单曲循环
changePlayListDispatch (sequencePlayList);
} else if (newMode === 2) {
// 随机播放
let newList = shuffle (sequencePlayList);
let index = findIndex (currentSong, newList);
changePlayListDispatch (newList);
changeCurrentIndexDispatch (index);
}
changeModeDispatch (newMode);
};
# 下滑关闭及反弹效果
作为一个精美的 App,在完成基本功能的同时,我们也有其他交互细节的考量。比如在安卓中下滑小段距离时会有反弹,下滑超过了一定阈值就会关闭浮层。现在就带大家来完成这个移动端常用的功能。
实现这个交互的关键在于利用好 touchStart, touchMove, touchEnd 这三个事件的回调。
首先来绑定事件:
const handleTouchStart = (e) => {};
const handleTouchMove = (e) => {};
const handleTouchEnd = (e) => {};
//...
<div
className="list_wrapper"
ref={listWrapperRef}
onClick={e => e.stopPropagation ()}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
其次,对于 Scroll 组件:
// 是否允许滑动事件生效
const [canTouch,setCanTouch] = useState (true);
const listContentRef = useRef ();
const handleScroll = (pos) => {
// 只有当内容偏移量为 0 的时候才能下滑关闭 PlayList。否则一边内容在移动,一边列表在移动,出现 bug
let state = pos.y === 0;
setCanTouch (state);
}
<Scroll
ref={listContentRef}
onScroll={pos => handleScroll (pos)}
bounceTop={false}
>
接下来我们来具体地编写那三个 touch 事件的回调函数。
首先初始化三个变量:
//touchStart 后记录 y 值
const [startY, setStartY] = useState (0);
//touchStart 事件是否已经被触发
const [initialed, setInitialed] = useState (0);
// 用户下滑的距离
const [distance, setDistance] = useState (0);
对于 touchStart 事件:
const handleTouchStart = (e) => {
if (!canTouch || initialed) return;
listWrapperRef.current.style ["transition"] = "";
setStartY (e.nativeEvent.touches [0].pageY);// 记录 y 值
setInitialed (true);
};
对于 touchMove 事件:
const handleTouchMove = (e) => {
if (!canTouch || !initialed) return;
let distance = e.nativeEvent.touches [0].pageY - startY;
if (distance < 0) return;
setDistance (distance);// 记录下滑距离
listWrapperRef.current.style.transform = `translate3d (0, ${distance} px, 0)`;
};
对于 touchEnd:
const handleTouchEnd = (e) => {
setInitialed (false);
// 这里设置阈值为 150px
if (distance >= 150) {
// 大于 150px 则关闭 PlayList
togglePlayListDispatch (false);
} else {
// 否则反弹回去
listWrapperRef.current.style ["transition"] = "all 0.3s";
listWrapperRef.current.style [transform] = `translate3d (0px, 0px, 0px)`;
}
};
恭喜你,现在终于开发完成了这个看似简单却实际上并不简单的 PlayList 组件。如今播放器的功能已经比较完整了,但是仍然有一个非常重要的功能需要完成 —— 歌词功能,下一节就让我们开始歌词开发的第一步 —— 歌词解析插件的封装。