简介
react-native-swiper是基于ScrollView封装的swiper组件,用于图片滑动展示、轮播图等场景。具体示例详见react-native-swiper。
核心功能
react-native-swiper实现了三大核心功能:分页、循环滚动、自动播放。
核心原理
分页
分页功能其实是由android和ios实现的,通过pagingEnable这个props来控制是否开启分页,当值为true时开启,如果用户不手动设置为false,react-native-swiper默认传入true。
循环滚动
可以预想,在现有的ScrollView中我们无法实现从最后一页到第一页的滚动,所以react-native-swiper在最后一页后额外增加了第一页的内容;同理,为了实现从第一页到最后一页的滚动,在第一页前额外增加了最后一页的内容。
像这种跟视图展现相关的代码我们在render中可以看到。
render() {
...
if (total > 1) {
// Re-design a loop model for avoid img flickering
pages = Object.keys(children)
if (loop) {
pages.unshift(total - 1 + '')
pages.push('0')
}
...
}
...
}
值得一提的是,为了优化性能,react-native-swiper还支持最小化加载。当我们传入loadMinimal: true时,只会加载视口页面和前后各1 loadMinimalSize大小的页面,其他页面用占位符来代替,loadMinimalSize默认为1。
如果开启了循环滚动,那么真实的第一页和最后一页将始终被渲染,后续会介绍原因。
相关代码。
if (
(i >= index + loopVal - loadMinimalSize &&
i <= index + loopVal + loadMinimalSize) ||
// The real first swiper should be keep
(loop && i === 1) ||
// The real last swiper should be keep
// 这里作者应该是写错了,等号右边应该是total
(loop && i === total - 1)
) {
return (
<View style={pageStyle} key={i}>
{children[page]}
</View>
)
}
以上的逻辑涉及到两个问题。
1. 启用了最小化加载,实际加载的页面和index有关,那么,在滚动的过程中需要维护index。
2. 首尾各新增了一页,那么用户在滚动到额外增加的页面时,我们需要手动将其挪到对应的真实页面。
先来解决第一个问题,index应该和视口可见的图片下标保持同步,而视口的切换是由于滑动这个行为导致的,所以应该在滑动结束后更新下标。
根据render函数的返回值可以找到渲染scrollView的方法renderScrollView。
return (
<View style={[styles.container, containerStyle]} onLayout={this.onLayout}>
{this.renderScrollView(pages)}
{showsPagination &&
(renderPagination
? renderPagination(index, total, this)
: this.renderPagination())}
{this.renderTitle()}
{showsButtons && this.renderButtons()}
</View>
)
在renderScrollView中我们可以找到动画结束后触发的回调函数this.onScrollEnd。
renderScrollView = pages => {
return (
<ScrollView
ref={this.refScrollView}
{...this.props}
{...this.scrollViewPropOverrides()}
contentContainerStyle={[styles.wrapperIOS, this.props.style]}
contentOffset={this.state.offset}
onScrollBeginDrag={this.onScrollBegin}
onMomentumScrollEnd={this.onScrollEnd}
onScrollEndDrag={this.onScrollEndDrag}
style={this.props.scrollViewStyle}
>
{pages}
</ScrollView>
)
在onScrollEnd这个方法中,我们可以看到他核心就是调用了三个方法。
updateIndex:更新index(对应第一个问题)
autoplay:自动播放
loopJump: 当滚动到额外增加的页面时,手动将其挪动到对应的真实页面(对应第二个问题)
onScrollEnd = e => {
...
this.updateIndex(e.nativeEvent.contentOffset, this.state.dir, () => {
this.autoplay()
this.loopJump()
})
...
}
先来看一下updateIndex的逻辑。
通过新旧offset相减算出是左滑还是右滑,再除以元素的宽度,得到下标是+1还是-1。由于快速连续滑动可能导致计算不准确,所以这里调用了parseInt和Math.round以保证正确性。
在算出新的index后,调用setState进行更新,setState会引起重新渲染。
updateIndex = (offset, dir, cb) => {
...
// offset记录的是最新的位置
// this.internals.offset记录的是旧有位置
const diff = offset[dir] - (this.internals.offset[dir] || 0)
const step = dir === 'x' ? state.width : state.height
let loopJump = false
// Do nothing if offset no change.
if (!diff) return
// Note: if touch very very quickly and continuous,
// the variation of `index` more than 1.
// parseInt() ensures it's always an integer
index = parseInt(index + Math.round(diff / step))
if (this.props.loop) {
// index记录的是真实页面的下标,不包括额外增加的页面
// index <= -1代表从第一页滚动到了最后一页
if (index <= -1) {
// 所以在这里我们把下标更新为真实页面的最后一页
index = state.total - 1
offset[dir] = step * state.total
loopJump = true
} else if (index >= state.total) {
// 同理,这里发生的是从最后一页滚动到了第一页
index = 0
offset[dir] = step
loopJump = true
}
}
const newState = {}
newState.index = index
newState.loopJump = loopJump
this.internals.offset = offset
// only update offset in state if loopJump is true
if (loopJump) {
// 以下是处理loopJump的代码,我认为作用是和下面的loopJump方法一致的
// 以下代码的触发时间比loopJump方法早,滚动体验会更流畅,我认为loopJump方法是冗余的
// 不知道为什么要将同样的功能实现两遍,可能是为了健壮性吧
if (offset[dir] === this.internals.offset[dir]) {
newState.offset = { x: 0, y: 0 }
newState.offset[dir] = offset[dir] + 1
this.setState(newState, () => {
this.setState({ offset: offset }, cb)
})
} else {
newState.offset = offset
this.setState(newState, cb)
}
} else {
this.setState(newState, cb)
}
}
将上面处理loopjump的代码摘出来单独说一下。
由于在上文已经赋值this.internals.offset = offset,所以我认为在loopJump为true时,只会走到以下分支。
newState.offset = { x: 0, y: 0 }
newState.offset[dir] = offset[dir] + 1
this.setState(newState, () => {
this.setState({ offset: offset }, cb)
})
那么为何要将offset存入state呢?
其实就是因为我们可以通过修改contentOffset实现无动画跳转,由额外增加的页面跳转到对应的真实页面。由于这个跳转的速度是非常快的,所以我们要保证滚动到的第一页或最后一页一定是已经渲染好的,这里就说明了为什么在开启了loadMinimal时,真实的第一页和最后一页始终被渲染。
renderScrollView = pages => {
return (
<ScrollView
...
contentOffset={this.state.offset}
...
>
{pages}
</ScrollView>
)
}
那么为什么要将offset先+1再-1呢?因为state中offset不是实时更新的,只有在onLayout和发生了loopJump才会更新,我们第一次发生loopJump和第二次发生loopJump的offset是一致的,两次传入一样的offset不会触发滚动(这个应该是anroid和ios上的实现逻辑)。
再来看一下loopJump的逻辑。
核心逻辑就是直接调用scrollTo方法,在禁用动画的情况下将其滚动到对应位置。
loopJump = () => {
// 未发生循环跳转,直接返回
if (!this.state.loopJump) return
// 其实可以不用判断this.props.loop,因为如果loop为false根本无法进行循环跳转
const i = this.state.index + (this.props.loop ? 1 : 0)
const scrollView = this.scrollView
this.loopJumpTimer = setTimeout(
() => {
// 历史原因,曾经在anroid端是基于ViewPagerAndroid做的封装
if (scrollView.setPageWithoutAnimation) {
scrollView.setPageWithoutAnimation(i)
} else {
if (this.state.index === 0) {
// 粗暴的滚动,禁用动画,其实会出现一下闪屏,体验不是很好
scrollView.scrollTo(
this.props.horizontal === false
? { x: 0, y: this.state.height, animated: false }
: { x: this.state.width, y: 0, animated: false }
)
} else if (this.state.index === this.state.total - 1) {
this.props.horizontal === false
? this.scrollView.scrollTo({
x: 0,
y: this.state.height * this.state.total,
animated: false
})
: this.scrollView.scrollTo({
x: this.state.width * this.state.total,
y: 0,
animated: false
})
}
}
},
// Important Parameter
// ViewPager 50ms, ScrollView 300ms
scrollView.setPageWithoutAnimation ? 50 : 300
)
}
自动播放
autoplay主要就是判断是否满足自动滚动的条件,满足则调用scrollBy方法。
autoplay = () => {
if (
// 无法自动播放的情况
// 1. 只有一个页面
// 2. 未开启自动播放
// 3. 正在滚动
// 4. 不能循环滚动 且 自动滚动方向向前且当前处于第一页 || 自动滚动方向向后且当前处于最后一页
!Array.isArray(this.state.children) ||
!this.props.autoplay ||
this.internals.isScrolling ||
this.state.autoplayEnd
)
return
this.autoplayTimer && clearTimeout(this.autoplayTimer)
this.autoplayTimer = setTimeout(() => {
// 以上所说的第四种情况
if (
!this.props.loop &&
(this.props.autoplayDirection
? this.state.index === this.state.total - 1
: this.state.index === 0)
)
return this.setState({ autoplayEnd: true })
// 核心方法
this.scrollBy(this.props.autoplayDirection ? 1 : -1)
}, this.props.autoplayTimeout * 1000)
}
scrollBy方法其实就是调用了ScrollView的scrollTo方法,在启用动画的情况下滚动到下一页/上一页。
scrollBy = (index, animated = true) => {
if (this.internals.isScrolling || this.state.total < 2) return
const state = this.state
const diff = (this.props.loop ? 1 : 0) + index + this.state.index
let x = 0
let y = 0
if (state.dir === 'x') x = diff * state.width
if (state.dir === 'y') y = diff * state.height
this.scrollView && this.scrollView.scrollTo({ x, y, animated })
// update scroll state
this.internals.isScrolling = true
this.setState({
autoplayEnd: false
})
...
}
总结
react-native-swiper虽然自称是the best swiper for react native,但其实在轮播的时候会有明显的闪屏,快速滑动的时候也会存在active dot和active page不同步的问题。此外,源码在逻辑上也存在一些错误和死代码(如果我没理解错的话)。如果有swiper的更佳实践,还请大家留言呀~