目录
- 前言
- 一、实现效果
- 二、具体实现
- 1. 导入依赖
- 2. 布局
- 3. Banner基础配置
- 4. Banner无限循环机制
- 5. 轮播适配器
- 6. 视频播放处理
- 7. 完整源码
- 总结
前言
我们日常的需求基本上都是图片的轮播,而在一些特殊需求,例如用于展览的的数据大屏,又想展示图片又想展示视频,本文将利用第三方库com.youth.play.banner轮播控件实现图片和视频混合轮播的效果,自动+手动滑动,无限循环,视频自动播放。
其中图片使用Glide图片加载库,视频使用GSYVideoPlayer播放器。
第三方库移步:【Android】常用的第三方开源库汇总
一、实现效果
二、具体实现
1. 导入依赖
implementation 'com.youth.banner:banner:2.1.0'
implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation 'com.shuyu:GSYVideoPlayer:7.1.8'
2. 布局
主页面:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.youth.banner.Banner
android:id="@+id/banner"
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_margin="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
这里没有使用库里面写好的指示器Indicator,需要的可以自行查看文档使用:Banner。
3. Banner基础配置
// 添加生命周期管理,确保在适当的生命周期内开始和停止轮播
banner.addBannerLifecycleObserver(this)
.setAdapter(adapter,true) //是否开启无限循环
.setLoopTime(loopTime) //轮播时间
// 设置轮播图的点击事件监听器
banner.addOnPageChangeListener(object :
OnPageChangeListener {
/**
* 滑动中的监听,当页面在滑动的时候会调用此方法,在滑动被停止之前,此方法会一直得到调用
*/
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
}
/**
* 监听滑动到对应索引值的页面,第一个页面不执行
*/
override fun onPageSelected(position: Int) {
}
/**
* 滑动状态监听
*/
override fun onPageScrollStateChanged(state: Int) {
}
})
Banner支持图片的无限循环,可以选择性打开。我们需要在其中插入视频的播放,就需要先了解其无限循环的处理机制。
4. Banner无限循环机制
无限循环主要有两种实现,一种是设置页面总数设置得很大,一直滑不到底,这不是真正的无限循环,是一种伪循环,性能较低。另外一种就是接下来要说的,Banner的无限循环,它是通过ViewPage2去实现的,在原有页面的基础上左右各添加一个页面,滑动到最后一页跳转到第二页,滑动到第一页跳转到倒数第二页。
这里为什么要多两个页面呢?一个目的是为了滑动到中间时,有一半是原页面,有一半是新页面。第二个目的是为了有滑动的效果,不会出现跳动。
首先,ViewPage2有三个监听方法:
onPageScrolled :滑动中的监听,当页面在滑动的时候会调用此方法,在滑动被停止之前,此方法会一直得到调用
onPageSelected :滑动或移动(左右滑动、点击指示器跳转)到某一页面时的监听,第一个页面不执行
onPageScrollStateChanged,滑动状态的监听
按理说,在滑动到最后一页时,我们只需要在 onPageSelected 中使用 setCurrentItem 跳转到第一页(总页数的第二页)即可,但是 onPageSelected 在滑动动画还没结束的时候就已经被调用了,此时调用 setCurrentItem 会强制取消当前正在进行的滑动动画并跳转,导致页面跳动不平滑。
所以可以这样去解决这一问题:
(1)先在 onPageSelected 中记录被选中的页面
(2)再在 onPageScrollStateChanged 判断当前动画是否结束,当动画结束时再去调用setCurrentItem方法跳转
我们查看Banner里面的源码也是这样处理无限循环的:
这里需要格外注意页面的下标,一个页面有两种下标,一个真实下标 realPosition,一个物理下标 position。同理,页面总数量、根据下标获取数据也是一样的逻辑。
以上面的图为例,下标如下
页面 | position | realPosition |
---|---|---|
第一页 | 0 | 2(最后一页) |
第二页 | 1 | 0 |
第三页 | 2 | 1 |
第四页 | 3 | 2 |
最后一页 | 4 | 0 (第一页) |
/**
* 获取真正的位置
*
* @param isIncrease 首尾是否有增加
* @param position 当前位置
* @param realCount 真实数量
* @return
*/
public static int getRealPosition(boolean isIncrease, int position, int realCount) {
if (!isIncrease) {
return position;
}
int realPosition;
if (position == 0) {
realPosition = realCount - 1;
} else if (position == realCount + 1) {
realPosition = 0;
} else {
realPosition = position - 1;
}
return realPosition;
}
5. 轮播适配器
//轮播数据
private val resourcesList by lazy{
arrayListOf<PlayResourcesBean>().apply {
add(PlayResourcesBean(isImg = true, title = "图片", url = "https://upload-images.jianshu.io/upload_images/5809200-a99419bb94924e6d.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://s10.mogucdn.com/mlcdn/c45406/170831_479g0ifl6f2i313feb5ech46kek21_778x440.jpg"))
add(PlayResourcesBean(isImg = false, title = "视频",url = "https://media.w3.org/2010/05/sintel/trailer.mp4"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://s10.mogucdn.com/mlcdn/c45406/170831_7gee6d620i774ec3l5bfh55cfaeab_778x440.jpg"))
add(PlayResourcesBean(isImg = false,title = "视频",url = "https://media.w3.org/2010/05/sintel/trailer.mp4"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://s10.mogucdn.com/mlcdn/c45406/170829_59ia6fd99ghkdkd9603kblha21h5b_778x440.jpg"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://img.zcool.cn/community/01233056fb62fe32f875a9447400e1.jpg"))
add(PlayResourcesBean(isImg = false,title = "视频",url = "https://media.w3.org/2010/05/sintel/trailer.mp4"))
}
}
private val adapter by lazy{
PlayAdapter(this,resourcesList, this)
}
@Keep
data class PlayResourcesBean(
/**
* 图片或者视频路径
*/
val url: String? = "",
val isImg:Boolean,
val title: String?=""
)
PlayAdapter需要继承BannerAdapter,处理两种不同类型的item(图片item和视频item)
class PlayAdapter(private val mContext: Context, private val dataList: ArrayList<PlayResourcesBean>,
private val mActivity: MainActivity
):
BannerAdapter<PlayResourcesBean, RecyclerView.ViewHolder>(dataList) {
private val mVHMap = HashMap<Int,RecyclerView.ViewHolder>()
private val options by lazy {
RequestOptions().priority(Priority.HIGH)
.placeholder(R.mipmap.default_ic)
.error(R.mipmap.default_ic)
}
companion object{
//视频
const val VIDEO = 1
//图片
const val IMAGE = 2
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
super.onBindViewHolder(holder, position, payloads)
val realPosition = getRealPosition(position)
if (holder is VideoHolder){
setVideo( holder, getData(realPosition),position,realPosition,realCount)
mVHMap[position] = holder
}else if(holder is ImageHolder){
setImage(holder,getData(realPosition),realPosition,realCount)
mVHMap[position] = holder
}
}
override fun getItemViewType(position: Int): Int {
//这里的position不是真实的坐标,获取数据需要转换
return if (getData(getRealPosition(position)).isImg){
IMAGE
}else {
VIDEO
}
}
override fun onCreateHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
val holder:RecyclerView.ViewHolder
val from = LayoutInflater.from(mContext)
if (viewType==VIDEO){
val view=from.inflate(R.layout.play_item_video,parent,false)
holder=VideoHolder(view)
}else {
val view=from.inflate(R.layout.play_item_image,parent,false)
holder=ImageHolder(view)
}
return holder
}
private fun setImage(
holder: ImageHolder,
data: PlayResourcesBean,
position: Int,
size: Int
) {
Glide.with(mContext)
.load(data.url)
.apply(options)
.into(holder.image)
holder.title.text=data.title?:""
holder.numIndicator.text="${position+1}/$size"
}
private fun setVideo(
holder: VideoHolder,
data: PlayResourcesBean,
position: Int,
realPosition: Int,
size: Int
) {
holder.video.apply {
setUp(data.url,true,mContext.externalCacheDir,null,"")
titleTextView.visibility = View.GONE
backButton.visibility = View.GONE
fullscreenButton.visibility = View.GONE
startButton.visibility = View.GONE
//音频焦点冲突时是否释放
isReleaseWhenLossAudio = true
//禁止全屏
isAutoFullWithSize = false
//isStartAfterPrepared=true
dismissControlTime=0
isClickable=false
isEnabled=false
isLongClickable=false
playPosition=position
//禁止滑动
setIsTouchWiget(false)
setVideoAllCallBack(object :GSYSampleCallBack(){
override fun onComplete(url: String?, vararg objects: Any?) {
super.onComplete(url, *objects)
mActivity.banner.isAutoLoop(true)
mActivity.banner.start()
}
override fun onPlayError(url: String?, vararg objects: Any?) {
super.onPlayError(url, *objects)
mActivity.banner.isAutoLoop(true)
mActivity.banner.start()
}
override fun onClickStop(url: String?, vararg objects: Any?) {
super.onClickStop(url, *objects)
mActivity.banner.isAutoLoop(true)
mActivity.banner.start()
}
override fun onPrepared(url: String?, vararg objects: Any?) {
super.onPrepared(url, *objects)
}
override fun onStartPrepared(url: String?, vararg objects: Any?) {
super.onStartPrepared(url, *objects)
}
override fun onAutoComplete(url: String?, vararg objects: Any?) {
super.onAutoComplete(url, *objects)
mActivity.banner.isAutoLoop(true)
//快速轮播到下一页
mActivity.banner.setLoopTime(100)
mActivity.banner.start()
mActivity.banner.setLoopTime(mActivity.loopTime)
}
})
}
holder.title.text=data.title?:""
holder.numIndicator.text="${realPosition+1}/$size"
}
internal class VideoHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val video: MyVideoPlayer
val title: TextView
val numIndicator:TextView
init {
video = itemView.findViewById(R.id.banner_vp)
title = itemView.findViewById(R.id.video_title)
numIndicator = itemView.findViewById(R.id.video_numIndicator)
}
}
internal class ImageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val image: ImageView
val title: TextView
val numIndicator:TextView
init {
image = itemView.findViewById(R.id.banner_image)
title = itemView.findViewById(R.id.image_title)
numIndicator = itemView.findViewById(R.id.image_numIndicator)
}
}
fun getVHMap(): HashMap<Int,RecyclerView.ViewHolder> {
return mVHMap
}
override fun onBindView(
holder: RecyclerView.ViewHolder?,
data: PlayResourcesBean?,
position: Int,
size: Int
) {
}
}
这里需要用一个HashMap去存储所有ViewHolder,以便后续控制视频自动播放,这里的key是物理下标,一个key对应一个页面,如果用realPosition作为key,不是一对一,容易产生value更新不及时。
6. 视频播放处理
GSYVideoPlayer 播放器的使用可以查看文档:GSYVideoPlayer
无限循环解决之后,就需要在轮播中自动播放视频,隐藏和关闭掉各种手动按钮和事件,需要对播放器进行改造(继承StandardGSYVideoPlayer),我是在翻看了它许多源码和github上的issues才得以解决。
class MyVideoPlayer(context: Context, attrs: AttributeSet?) : StandardGSYVideoPlayer(context,attrs){
override fun changeUiToPreparingShow() {
super.changeUiToPreparingShow()
hide()
}
override fun changeUiToPauseShow() {
super.changeUiToPauseShow()
hide()
}
override fun changeUiToError() {
super.changeUiToError()
hide()
}
override fun changeUiToCompleteShow() {
super.changeUiToCompleteShow()
hide()
}
override fun changeUiToPlayingBufferingShow() {
super.changeUiToPlayingBufferingShow()
hide()
}
override fun changeUiToPrepareingClear() {
super.changeUiToPrepareingClear()
hide()
}
override fun changeUiToPlayingClear() {
super.changeUiToPlayingClear()
hide()
}
override fun changeUiToPauseClear() {
super.changeUiToPauseClear()
hide()
}
override fun changeUiToPlayingBufferingClear() {
super.changeUiToPlayingBufferingClear()
hide()
}
override fun changeUiToClear() {
super.changeUiToClear()
hide()
}
override fun changeUiToCompleteClear() {
super.changeUiToCompleteClear()
hide()
}
override fun changeUiToPlayingShow() {
super.changeUiToPlayingShow()
hide()
}
override fun changeUiToNormal() {
super.changeUiToNormal()
hide()
}
private fun hide() {
setViewShowState(mTopContainer, INVISIBLE)
setViewShowState(mBottomContainer, INVISIBLE)
setViewShowState(mStartButton, INVISIBLE)
setViewShowState(mLoadingProgressBar, INVISIBLE)
setViewShowState(mThumbImageViewLayout, INVISIBLE)
setViewShowState(mBottomProgressBar, INVISIBLE)
setViewShowState(mLockScreen, GONE)
}
override fun onVideoPause() {
super.onVideoPause()
mPauseBeforePrepared=false //是否准备完成前调用了暂停,避免再次暂停
}
override fun touchDoubleUp() {
//双击会暂停,重载清除
}
}
StandardGSYVideoPlayer是GSYVideoPlayer中的一个标准样式的播放器,在视频准备、暂停、播放完成等等状态隐藏和关闭掉各种手动按钮和事件,设置mPauseBeforePrepared避免视频准备完成后自动暂停,重载touchDoubleUp清除掉原有的双击屏幕暂停视频的效果。
视频自动播放:
接下来就是主要逻辑了,这里有几个关键点:
在轮播到视频时,将自动轮播关闭,同时播放视频
在视频播放错误、非正常完成时,暂停视频,同时继续轮播
在视频播放正常完成、手动滑动到下一页时,立即轮播到下一页
首先,就需要在MyVideoPlayer适配器中处理视频控件的各种监听事件(查看源码视频有各种状态):
setVideoAllCallBack(object :GSYSampleCallBack(){
override fun onComplete(url: String?, vararg objects: Any?) {
super.onComplete(url, *objects)
mActivity.banner.isAutoLoop(true)
mActivity.banner.start()
}
override fun onPlayError(url: String?, vararg objects: Any?) {
super.onPlayError(url, *objects)
mActivity.banner.isAutoLoop(true)
mActivity.banner.start()
}
override fun onClickStop(url: String?, vararg objects: Any?) {
super.onClickStop(url, *objects)
mActivity.banner.isAutoLoop(true)
mActivity.banner.start()
}
override fun onPrepared(url: String?, vararg objects: Any?) {
super.onPrepared(url, *objects)
}
override fun onStartPrepared(url: String?, vararg objects: Any?) {
super.onStartPrepared(url, *objects)
}
override fun onAutoComplete(url: String?, vararg objects: Any?) {
super.onAutoComplete(url, *objects)
mActivity.banner.isAutoLoop(true)
//设置轮播时间,立即轮播到下一页
mActivity.banner.setLoopTime(100)
mActivity.banner.start()
mActivity.banner.setLoopTime(mActivity.loopTime)
}
})
其次,在Activity中设置任务,在页面切换时通过之前保存的ViewHolder去执行视频播放或者暂停的任务:
private val taskHandler by lazy{
Handler(Looper.getMainLooper())
}
val task = Runnable {
//可能是首尾切换页,两个页面循环跳转
if(adapter.getVHMap().containsKey(currentPos)){
if(adapter.getVHMap()[currentPos] is PlayAdapter.VideoHolder){
val holder = (adapter.getVHMap()[currentPos] as PlayAdapter.VideoHolder)
GSYVideoManager.onPause()
holder.video.startPlayLogic()
banner.isAutoLoop(true)
banner.stop()
banner.isAutoLoop(false)
}else{
GSYVideoManager.onPause()
banner.isAutoLoop(true)
banner.start()
}
}else{
GSYVideoManager.onPause()
banner.isAutoLoop(true)
banner.start()
}
}
// 设置轮播图的点击事件监听器
banner.addOnPageChangeListener(object :
OnPageChangeListener {
/**
* 滑动中的监听,当页面在滑动的时候会调用此方法,在滑动被停止之前,此方法会一直得到调用
*/
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
}
/**
* 监听滑动到对应索引值的页面,第一个页面不执行
*/
override fun onPageSelected(position: Int) {
if (position == banner.realCount - 1) {
currentPos=banner.realCount
} else if (position == 0) {
currentPos = 1
} else {
currentPos = position + 1
}
}
/**
* 滑动状态监听
*/
override fun onPageScrollStateChanged(state: Int) {
// Banner跳转之后再去控制视频播放
if (state == ViewPager2.SCROLL_STATE_IDLE) {
if (banner.isInfiniteLoop) {
taskHandler.post(task)
}
}
}
})
任务一定要在onPageScrollStateChanged中去执行,这时候特殊的两个页面已经跳转完成了,只会执行一次任务,否则执行两个异步任务会导致一个视频播放一个视频暂停,两个异步没有固定的执行顺序,出现视频没有画面但是有声音的问题。
完整的Activity代码:
class MainActivity:AppCompatActivity() {
val loopTime=6000L
//列表+前后两页过渡页的坐标
private var currentPos= 1
private val taskHandler by lazy{
Handler(Looper.getMainLooper())
}
private val adapter by lazy{
PlayAdapter(this,resourcesList, this)
}
private val resourcesList by lazy{
arrayListOf<PlayResourcesBean>().apply {
add(PlayResourcesBean(isImg = true, title = "图片", url = "https://upload-images.jianshu.io/upload_images/5809200-a99419bb94924e6d.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://s10.mogucdn.com/mlcdn/c45406/170831_479g0ifl6f2i313feb5ech46kek21_778x440.jpg"))
add(PlayResourcesBean(isImg = false, title = "视频",url = "https://media.w3.org/2010/05/sintel/trailer.mp4"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://s10.mogucdn.com/mlcdn/c45406/170831_7gee6d620i774ec3l5bfh55cfaeab_778x440.jpg"))
add(PlayResourcesBean(isImg = false,title = "视频",url = "https://media.w3.org/2010/05/sintel/trailer.mp4"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://s10.mogucdn.com/mlcdn/c45406/170829_59ia6fd99ghkdkd9603kblha21h5b_778x440.jpg"))
add(PlayResourcesBean(isImg = true,title = "图片",url = "https://img.zcool.cn/community/01233056fb62fe32f875a9447400e1.jpg"))
add(PlayResourcesBean(isImg = false,title = "视频",url = "https://media.w3.org/2010/05/sintel/trailer.mp4"))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initBanner()
}
private fun initBanner() {
// 添加生命周期管理,确保在适当的生命周期内开始和停止轮播
banner.addBannerLifecycleObserver(this)
.setAdapter(adapter,true) //是否开启无限循环
.setLoopTime(loopTime) //轮播时间
// 设置轮播图的点击事件监听器
banner.addOnPageChangeListener(object :
OnPageChangeListener {
/**
* 滑动中的监听,当页面在滑动的时候会调用此方法,在滑动被停止之前,此方法会一直得到调用
*/
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
}
/**
* 监听滑动到对应索引值的页面,第一个页面不执行
*/
override fun onPageSelected(position: Int) {
if (position == banner.realCount - 1) {
currentPos=banner.realCount
} else if (position == 0) {
currentPos = 1
} else {
currentPos = position + 1
}
}
/**
* 滑动状态监听
*/
override fun onPageScrollStateChanged(state: Int) {
// Banner跳转之后再去控制视频播放
if (state == ViewPager2.SCROLL_STATE_IDLE) {
if (banner.isInfiniteLoop) {
taskHandler.post(task)
}
}
}
})
//重新设置banner数据
//banner.setDatas(resourcesList)
}
val task = Runnable {
//可能是首尾切换页,两个页面循环跳转
if(adapter.getVHMap().containsKey(currentPos)){
if(adapter.getVHMap()[currentPos] is PlayAdapter.VideoHolder){
val holder = (adapter.getVHMap()[currentPos] as PlayAdapter.VideoHolder)
GSYVideoManager.onPause()
holder.video.startPlayLogic()
banner.isAutoLoop(true)
banner.stop()
banner.isAutoLoop(false)
}else{
GSYVideoManager.onPause()
banner.isAutoLoop(true)
banner.start()
}
}else{
GSYVideoManager.onPause()
banner.isAutoLoop(true)
banner.start()
}
}
override fun onResume() {
super.onResume()
taskHandler.post(task)
}
override fun onPause() {
super.onPause()
GSYVideoManager.onPause()
}
override fun onDestroy() {
super.onDestroy()
GSYVideoManager.releaseAllVideos()
//移除数据绑定,否则第二次设置适配器出错
banner.destroy()
}
}
7. 完整源码
到这就完结撒花了🎉,完整代码我已上传github,需要的可以自行pull:https://github.com/FullCourage/BannerPlayer
总结
这个项目让我深刻体会到源码的重要性,解决jar包的一些底层问题,不仅要知道原理,还要学会从源码中了解它的执行逻辑。