前段时间听一个群友说可以使用expo创建react native应用,不必使用~~react native官网提供的开发环境搭建和脚手架,~~我就尝试了Expo,由于没有使用过RN原生开发,但是被RN搭建环境操作狠狠按在地上摩擦过,总体来说在Expo的加持下开发体验很好,我也是初次编写RN,自己尝试模仿手机自带的音乐播放器进行编写
掘金上发布文章的页面格式我还是不太懂,看以来不够优雅,大家就雅俗共赏吧 首先说一下组件效果:
该歌词组件功能:
- 歌词列表自上而下排列
- 歌曲播放时根据播放进度时间,对应歌词高亮显示,歌词自动滚动
- 随着歌曲播放歌词正常高亮行在列表中间
- 歌词列表可以拖动,拖动时显示跳转到指定歌曲功能,覆盖到某一行时歌词额外高亮
关于歌词相关的依赖库我搜索到了一个好像叫react-native-lyrics,查看了一下代码使用了ScrollView, 我觉得不是很好,我使用了FlatList!
整个组件遇到的最大问题是: 1.列表滚动时如何获取列表可视区域的中间歌词项,我重点也是要说明它的得出方式(已解决), 2.歌词列表组件自身的滚动事件可能会和父组件的额外事件冲突(没有较好方案)
先简单看一下组件接收的配置,其实核心功能相关的有限,有些配置项可以按需完善
tsx
type ImpreativeType = {
getCurrentLine: () => {
index: number;
lrcLine: { time: number; word: string } | null;
};
}
type ShadowConfig = {
colors: readonly [ColorValue, ColorValue, ...ColorValue[]]
start?: { x: number, y: number }
end?: { x: number, y: number },
locations?: number[]
}
type Props = {
lyrics?: { time: number; word: string }[]
currentTime?: number
lineHeight?: number//行高
highLightColor?: ColorValue//高亮歌词颜色ViewStyle
basicColor?: ColorValue
viewPosition?: number//0~1歌词位置
/**单行歌词渲染函数,height要和传入的height一致 注意要返回ReactElement类型,ReactNode类型没有style会报错 */
renderItem?: (item: { time: number; word: string }, index: number, active: boolean, middleIndex: number) => ReactElement
scrollEnabled?: boolean//能否手动滚动
/**当拖拽时可能与外部元素某些事件冲突,drug发生时应当阻止外部事件 */
isDrug?: boolean
setIsDrug?: (v: boolean) => void
showShadow?: boolean//是否显示列表顶部和底部背景阴影
shadowHeight?: number
shadowConfig?: {
topShadow: ShadowConfig
bottomShadow: ShadowConfig
},
/**列表头部尾部空白元素高度 */
listTopHeight?: number
listBottomHeight?: number
seekTo?: (time: number) => void
middleItemShowTime?: number,//歌词停留显示时间,毫秒
}
以上有一些关键配置项,直接影响组件的运行:
- lyrics就是歌词列表,lrc歌词文件最终被转化为带有时间和歌词的数组项的数组
- currentTime 当前歌曲播放到了第几秒
- lineHeight 每行歌词的具体高度,因为这个值会影响列表当前中间要跳转播放项的计算
- isDrug 如果你的列表外层还有其他的事件需要配置,并在列表拖拽时不要执行其他事件
- setIsDrug 同上,就是列表外层有press事件会在触发时和列表的scroll冲突
- viewPosition 控制正常播放时,列表中当前播放的高亮歌词位置
- listTopHeight /listBottomHeight 它们是列表中头尾部插入的除了列表项的额外组件,目的是在拖动时可以保证中间高亮项可以选到歌词的第一行和最后一行
接下来是功能分析实现:
- 歌词列表随着歌曲播放进度滚动到指定行高亮
tsx
/**将监听currentTime变化,返回当前歌词索引 */
export const useCurrentIndex = (lyrics: { word: string; time: number }[], currentTime: number) => {
const [currentIndex, setCurrentIndex] = useState(-1);
useEffect(() => {
const currentIndex = lyrics.findIndex((line, index) => currentTime >= line.time &&
(index === lyrics.length - 1 || currentTime < lyrics[index + 1].time)
);
setCurrentIndex(currentIndex);
}, [currentTime, lyrics]);
return currentIndex;
};
/**当前滚动到的index使用自定义hooks */
const currentIndex = useCurrentIndex(lyrics, currentTime);
useEffect(() => {
if (lyrics.length === 0 || !flatListRef.current || isDrug) {
return;
}
/**如果正在拖动就不要跳转到指定index */
flatListRef.current?.scrollToIndex({
animated: true,
index: currentIndex === -1 ? 0 : currentIndex,//若歌没唱完但是歌词没了就停在最后一个高亮
viewPosition,// 0~1的比例,将滚动到的项的位置放在列表中间
viewOffset: -listTopHeight,//抵消
})
}, [currentIndex])
/**歌词样式渲染 传递给FlatList的renderItem属性*/
const renderer = (item: { time: number, word: string }, index: number) => {
let active = currentIndex === index;
if (renderItem) {
return renderItem(item, index, active, middleIndexRef.current)
}
const bool = middleIndexRef.current === index && isDrug;
return (
<Animated.Text
style={{
height: lineHeight,
fontSize: 16,
fontWeight: bool ? 'bold' : '400',
color: active || bool ? highLightColor : basicColor,
}}
>
{item.word}
</Animated.Text>
)
}
核心使用了FlatList组件的scrollToIndex方法,监听当前播放的是哪一行歌词使得列表滚动到指定行,歌词样式修改则是currentIndex是否等于当前的歌词index,viewPosition在这里使用到了,它的取值范围是0~1,0.5表示列表中间位置
2.当用户手动拖列表时,应当计算出当前列表可视区域的列表中间项,并显示中间的白色区域,核心在于手动计算 中间行高亮样式也在renderer中做了处理
tsx
/**初始化时测算列表总高度 */
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
listHeightRef.current = height;
}, [lyrics, listTopHeight, listBottomHeight])
/**开始滚动触发 */
const handleDrugBegin = () => {
setIsDrug && setIsDrug(true)
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
setMiddleShow(true)
}
/**滚动停止时触发 */
const handleDrugEnd = () => {
if (!setIsDrug || isDrug === undefined) {
return
}
setIsDrug(false)
/**指定时间后隐藏中间项 */
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = setTimeout(() => {
setMiddleShow(false)
}, middleItemShowTime)
}
/** 计算当前哪一行是用户可以跳转到播放的歌词 */
const calcCurrentLine = (currentScrollY: number) => {
let distance = currentScrollY - listTopHeight;//listTopHeight240:顶部占位
let res = initialMiddleIndexRef.current;
if (distance === 0) {
res = initialMiddleIndexRef.current;//就是6,即lyrics的第7个元素
} else {
/**
* 如果distance是大于0,表示表示向上滚动了
* 如果distance是小于0,表示表示向下滚动了
*/
let index = Math.ceil(distance / lineHeight)
res = index + initialMiddleIndexRef.current;
}
if (res > lyrics.length - 1) {
res = lyrics.length - 1
}
return res;
}
/**记录更新更前滚动的位置 */
const handleScroll = ({ nativeEvent }: NativeSyntheticEvent<NativeScrollEvent>) => {
const { y } = nativeEvent.contentOffset;
let index = calcCurrentLine(y)
middleIndexRef.current = index;
}
FlatList组件在如何获取当前列表可视区域中间的项提供了一个方法onViewableItemsChanged,可以通过它计算出 当前列表可视区域中哪一项是中间项,但是它会在设置了ListEmptyComponent属性后出现计算问题,如果不使用 ListEmptyComponent进行留白(为了用户滚动时可以选中歌词第一行和最后一行),它就可以满足如上需求了
tsx
/**
*使用FlatList组件的onViewableItemsChanged
* 搭配viewabilityConfig配置项
* 来获取中间位置的item,但是有明显缺陷:
* 在设置列表的ListHeaderComponent后
* 列表项不是单纯的viewableItems.length / 2
* 会导致不准确,放弃该方案
* 直接监听滚动事件是明智之举
*/
const onViewableItemsChanged = ({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (viewableItems.length > 0) {
// 获取中间位置的item
const middleIndex = Math.floor(viewableItems.length / 2)
console.log(viewableItems[middleIndex].index, '计算得出的中间index');
setMiddleItem(viewableItems[middleIndex].item);
}
};
如上这种方法折磨了我很长时间,计算不准和viewableItems.length的变化很迷惑,最后采用了根据列表整体高度-顶部留白高度 和每行歌词的高度计算得出应该跳转到的歌词高亮行
下面贴一下完整代码:
tsx
import {
type ColorValue, FlatList, View, Text, StyleSheet, Pressable,
type NativeSyntheticEvent, type NativeScrollEvent, type LayoutChangeEvent
} from 'react-native';
import React, { useRef, useEffect, forwardRef, useState, useImperativeHandle, useCallback, type FC, type ReactElement } from 'react';
import Animated from 'react-native-reanimated';
import { useCurrentIndex } from './musicplayer/hooks';
import { LinearGradient } from 'expo-linear-gradient'
import { Ionicons } from '@expo/vector-icons'
import { formatTime } from '@/utils'//就是将秒数转化为0:00格式的函数
type ImpreativeType = {
getCurrentLine: () => {
index: number;
lrcLine: { time: number; word: string } | null;
};
}
type ShadowConfig = {
colors: readonly [ColorValue, ColorValue, ...ColorValue[]]
start?: { x: number, y: number }
end?: { x: number, y: number },
locations?: number[]
}
type Props = {
lyrics?: { time: number; word: string }[]
currentTime?: number
lineHeight?: number//行高
highLightColor?: ColorValue//高亮歌词颜色ViewStyle
basicColor?: ColorValue
viewPosition?: number//0~1歌词位置
/**单行歌词渲染函数,height要和传入的height一致 注意要返回ReactElement类型,ReactNode类型没有style会报错 */
renderItem?: (item: { time: number; word: string }, index: number, active: boolean, middleIndex: number) => ReactElement
scrollEnabled?: boolean//能否手动滚动
/**当拖拽时可能与外部元素某些事件冲突,drug发生时应当阻止外部事件 */
isDrug?: boolean
setIsDrug?: (v: boolean) => void
showShadow?: boolean//是否显示列表顶部和底部背景阴影
shadowHeight?: number
shadowConfig?: {
topShadow: ShadowConfig
bottomShadow: ShadowConfig
},
/**列表头部尾部空白元素高度 */
listTopHeight?: number
listBottomHeight?: number
seekTo?: (time: number) => void
middleItemShowTime?: number,//歌词停留显示时间,毫秒
}
const LyricsView: FC<Props> = forwardRef<ImpreativeType, Props>(({
highLightColor = '#fff', basicColor = '#666', currentTime = 100, isDrug, setIsDrug, shadowHeight = 50,
lineHeight = 40, viewPosition = .5, lyrics = [], renderItem, scrollEnabled = true, showShadow = true,
shadowConfig, listTopHeight = 240, listBottomHeight = 240, seekTo, middleItemShowTime = 3000
}, ref) => {
/**记录FlatList的高度 */
const listHeightRef = useRef<number>(0);
/**初始中间项列表下标 */
const initialMiddleIndexRef = useRef<number>(-1);
// 存储定时器
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const middleIndexRef = useRef<number>(0);
/**设置FlatList可视区域顶部和底部的渐变色阴影效果 */
const { topShadow, bottomShadow } = Object.assign(shadowConfig ?? {}, {
topShadow: {
colors: ['rgba(0,0,0,.06)', 'rgba(0,0,0,.015)',],
start: { x: .5, y: 0 },
end: { x: .5, y: 1 },
},
bottomShadow: {
colors: ['rgba(0,0,0,.015)', 'rgba(0,0,0,.006)'],
start: { x: .5, y: 0 },
end: { x: .5, y: 1 },
},
})
/**滚动的白色遮罩 */
const [middleShow, setMiddleShow] = useState<boolean>(false);
/**当前滚动到的index使用自定义hooks useCurrentIndex的代码在上面*/
const currentIndex = useCurrentIndex(lyrics, currentTime);
const flatListRef = useRef<FlatList>(null);
// 找到当前时间对应的歌词行
const getCurrentLine = () => ({ index: currentIndex, lrcLine: lyrics[currentIndex] || null, });
// 自动滚动到当前歌词行
useEffect(() => {
if (lyrics.length === 0 || !flatListRef.current || isDrug) {
return;
}
/**如果正在拖动就不要跳转到指定index */
flatListRef.current?.scrollToIndex({
animated: true,
index: currentIndex === -1 ? 0 : currentIndex,//若歌没唱完但是歌词没了就停在最后一个高亮
viewPosition,// 0~1的比例,将滚动到的项的位置放在列表中间
viewOffset: -listTopHeight,//抵消
})
}, [currentIndex])
/**初始化滚动过空白行 */
useEffect(() => {
if (flatListRef.current) {
flatListRef.current.scrollToOffset({ offset: listTopHeight })
}
}, [])
useEffect(() => {
/**初始的 中间项,列表项高度,和FlatList的高度变化时计算 */
initialMiddleIndexRef.current = (Math.floor(listHeightRef.current / 2 / lineHeight))
}, [lineHeight, listHeightRef.current])
useImperativeHandle(ref, () => ({ getCurrentLine }))
/**歌词样式渲染 */
const renderer = (item: { time: number, word: string }, index: number) => {
let active = currentIndex === index;
if (renderItem) {
return renderItem(item, index, active, middleIndexRef.current)
}
const bool = middleIndexRef.current === index && isDrug;
return (
<Animated.Text
style={{
height: lineHeight,
fontSize: 16,
fontWeight: bool ? 'bold' : '400',
color: active || bool ? highLightColor : basicColor,
}}
>
{item.word}
</Animated.Text>
)
}
/**开始滚动触发 */
const handleDrugBegin = () => {
setIsDrug && setIsDrug(true)
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
setMiddleShow(true)
}
/**滚动停止时触发 */
const handleDrugEnd = () => {
if (!setIsDrug || isDrug === undefined) {
return
}
setIsDrug(false)
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = setTimeout(() => {
setMiddleShow(false)
}, middleItemShowTime)
}
/**跳转到拖动到的歌词项的时间,实现歌词播放 */
const handlePress = () => {
if (seekTo) {
seekTo(lyrics?.[middleIndexRef.current]?.time ?? 0)
}
}
/**记录更新更前滚动的位置 */
const handleScroll = ({ nativeEvent }: NativeSyntheticEvent<NativeScrollEvent>) => {
const { y } = nativeEvent.contentOffset;
let index = calcCurrentLine(y)
middleIndexRef.current = index;
}
/**测算列表高度 */
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
listHeightRef.current = height;
}, [lyrics, listTopHeight, listBottomHeight])
const calcCurrentLine = (currentScrollY: number) => {
let distance = currentScrollY - listTopHeight;//listTopHeight240:顶部占位
let res = initialMiddleIndexRef.current;
if (distance === 0) {
res = initialMiddleIndexRef.current;//就是6,即lyrics的第7个元素
} else {
/**
* 如果distance是大于0,表示表示向上滚动了
* 如果distance是小于0,表示表示向下滚动了
*/
let index = Math.ceil(distance / lineHeight)
res = index + initialMiddleIndexRef.current;
}
if (res > lyrics.length - 1) {
res = lyrics.length - 1
}
return res;
}
return (
<View style={styles.container}>
<FlatList
onLayout={handleLayout}//测量list高度
onScroll={handleScroll}
ListEmptyComponent={() => (
<View style={{ height: 200 }}>
<Text style={{ textAlign: 'center', color: '#fff', fontWeight: 700 }}>暂无歌词</Text>
</View>
)}
ListHeaderComponent={() => (<View style={{ height: listTopHeight }} />)}
ListFooterComponent={() => (<View style={{ height: listBottomHeight }} />)}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator={false}
ref={flatListRef}
data={lyrics}
keyExtractor={(_, index) => `${index}`}
/**
* 这里最好不要限制Animated.ScrollView的 scrollEventThrottle
* ,如果外层没有其他事件监听,限制更好,否则会影响手动滚动效果
* */
renderScrollComponent={props => (<Animated.ScrollView {...props} />)}
renderItem={({ item, index }) => renderer(item, index)}//自定义的渲染组件
getItemLayout={(_, index) => ({
length: lineHeight, // 每项固定高度 ITEM_HEIGHT 确实高度 用length
offset: lineHeight * index, // 累计偏移量 它会影响高亮项的位置
index, // 当前索引
})}
onScrollBeginDrag={handleDrugBegin}
onScrollEndDrag={handleDrugEnd}
onScrollToIndexFailed={({ index }) => {
flatListRef.current?.scrollToOffset({
offset: index * lineHeight,
animated: true,
})
}}
/>
<Pressable style={[styles.middle, { display: middleShow ? 'flex' : 'none' }]}>
<LinearGradient
colors={['rgba(255,255,255,.06)', 'rgba(255,255,255,.015)',]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.middle}
>
<Pressable style={styles.playIcon} onPress={handlePress} >
<Ionicons name="play" size={15} color='#fff' />
<Text style={{ color: '#fff', fontSize: 12 }}>{formatTime(lyrics?.[middleIndexRef.current]?.time ?? 0)}</Text>
</Pressable>
</LinearGradient>
</Pressable>
<LinearGradient
colors={topShadow.colors as any}
start={topShadow.start}
end={topShadow.end}
style={[styles.shadow, styles.topShadow, { display: showShadow ? 'flex' : 'none' }, { height: shadowHeight, }]}
/>
<LinearGradient
colors={bottomShadow.colors as any}
start={bottomShadow.start}
end={bottomShadow.end}
style={[styles.shadow, styles.bottomShadow, { display: showShadow ? 'flex' : 'none' }, { height: shadowHeight, }]}
/>
</View>
);
});
const styles = StyleSheet.create({
container: {
position: 'relative',
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20
},
middle: {
height: 40,
width: '100%',
position: 'absolute',
flexDirection: 'row',
justifyContent: 'flex-end',
},
playIcon: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 15,
gap: 5
},
shadow: {
position: 'absolute',
height: 50,
width: '100%',
},
topShadow: {
top: 0,
},
bottomShadow: {
bottom: 0,
},
})
export default LyricsView;
已知问题:如果歌词组件外部组件有press事件(播放器页面点击歌词页面切换到音乐海报页面的切换效果)它会优先于FlatList组件的Scroll事件触发,导致用户拖动歌词效果不好,或者直接触发父组件的Press事件,因此允许父组件传递进来isDrug和setIsDrug,在FlatList组件开始滚动时修改该状态,父组件则在此值为true时不触发press事件,在,结束滚动时修改isDrug让父组件的Press事件可以触发。目前没有好的思路,期待大神帮忙解决
其实我是录屏了一段效果,但是没玩西瓜视频就不上视频了,另外react-track-player依赖库在expo go上没法调试,音乐播放部分暂时用expo-audio实现的,音乐播放器进入后台播放没有实现,之前没有APP开发经验,希望大神指教
文笔有限,有一些细节没有详细展开说,因为我发现RN板块好像不是很活跃,国内RN应用好像不是特别多,代码地址在gitee上,test分支,欢迎交流学习!