🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
# 1.回顾 对于自定义ViewGroup,那么必须要实现其抽象方法onLayout,也就是负责把childView放入指定的位置。那么我们就需要知道其子控件的布局大小,然后就是把它们放进它们该放的地方去。 # 2. 实现 首先定义一个ViewGroup,然后按照平铺的方式来放置ImageView。也就是: ~~~ // MyViewPager class MyViewPager(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) { override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { for(i in 0..childCount){ val childView = getChildAt(i) childView?.layout(i * width, 0, (i + 1) * width, height) } } } ~~~ 在MainActivity中进行添加图片: ~~~ class MainActivity : AppCompatActivity() { val custom_viewpager by lazy { findViewById<MyViewPager>(R.id.custom_viewpager) } var imageRes = listOf<Int>(R.drawable.a, R.drawable.b, R.drawable.c) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) for (i in 0 until imageRes.size){ val imageView = ImageView(this) imageView.setBackgroundResource(imageRes.get(i)) custom_viewpager.addView(imageView) } } } ~~~ ~~~ // activity_main.xml <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <com.weizu.custionviewpager.MyViewPager android:id="@+id/custom_viewpager" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout> ~~~ 然后就可以做到正常显示。 :-: ![](https://img.kancloud.cn/d3/af/d3afe9db3f01be9d577a139aabdfdaa5_377x627.png) 但是,很明显这里直接全屏了,而有些时候我们在定义其android:layout_width和android:layout_height属性的时候希望可以做到预期的效果。所以这里我们需要在自定义的MyViewPager中复写一下onMeasure方法: 也就是: ~~~ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 获取子元素个数 if(childCount == 0) return // 如果是wrap_content,也就是AT_MOST模式 // 如果是match_parents,也就是精确模式 val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) // 自适应,也就是取决于最大的子元素的宽和高,这里直接设置为屏幕的宽和高 if(widthMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels) } } ~~~ 其实这里不处理也默认就是填充整个屏幕。 ## 2.1 设置滑动 为了设置可以滑动,这里需要设置屏幕的触摸事件,也就是使用onTouchEvent来处理。那么在滑动的时候,在Android中可选择的动画效果就有如下几种方案: * 使用属性动画; * 使用View动画; * 使用前面案例中所使用过的自己布局margin来实现; * 使用scrollBy或者scrollTo; 注意到前面的几种其实都是来改变view自己的动画,也就是怎么移动,移动的对象是自己。而对于scrollBy或者scrollTo却是相对于内容而言的。也就是说它只能滑动View的内容,并不能滑动View本身。 注意到在前面的案例中,我们还没有尝试过使用scrollBy或者scrollTo,且因为它比较简单,且可以实现滑动其View中的内容,所以这里选择使用它。 那么,在使用之前,这里先再次复习一下: * 调用View的scrollTo()和scrollBy()是用于滑动View中的内容,而不是把某个View的位置进行改变。 * 以当前视图以左上角为原点的坐标,当前控件里面的内容偏移坐标的距离或者叫偏移量; * 如果需要显示当前视图的右边的一个页面,那么对于scrollTo就需要传入的就是预期视图的起始坐标值,也就是对于scrollBy来说需要传入一个正值,也就是常说的左移为正,右移为负。 ~~~ public void scrollBy(int x, int y) { scrollTo(mScrollX + x, mScrollY + y); } ~~~ ![](https://img.kancloud.cn/23/52/23521ab8faec57207cd5253f7adef4bf_1254x572.png) ## 2.2 实现 这里简单实现一下,也就是计算按下前后的坐标差,然后计算行为: ~~~ var scrollStartX = 0f var startX = 0f var index = 0 override fun onTouchEvent(event: MotionEvent?): Boolean { when(event?.action){ MotionEvent.ACTION_DOWN -> { // 起始位置 startX = event.x scrollStartX = event.x } MotionEvent.ACTION_MOVE -> { // 计算视图移动距离,由于是内容移动,刚好相反,所以这里用起始值减去当前值 val offset = (scrollStartX - event.x).toInt() // 判断逻辑offset是否有效,需要屏蔽无效值 val toVal = scrollX + offset if(toVal < 0){ // 设置为左边界值 scrollTo(0, 0) scrollStartX = 0f } else if(toVal > (childCount - 1) * width){ // 设置为右边界值 scrollTo((childCount - 1) * width, 0) scrollStartX = ((childCount - 1) * width).toFloat() } else{ // 合法值 scrollBy(offset, 0) scrollStartX = event.x } } MotionEvent.ACTION_UP -> { // 设置回弹效果,也就是如果当前的offset大于width/2 val offset = (startX - event.x).toInt() var isBounds = false if(Math.abs(offset) >= width / 2){ isBounds = true } // 如果需要回弹效果 if(isBounds){ if(offset > 0){ index++ } else if(offset < 0){ index-- } scrollTo(index * width, 0) startX = (index * width).toFloat() } else{ // 不需要回弹效果,表示没到一半,也就是需要返回原来的 scrollTo(index * width, 0) startX = (index * width).toFloat() } } } return true } ~~~ 但是,对于在ACTION_UP中的设置回弹的效果太快了,也就是太生硬了,这里需要做一些简单的处理,使之更加平滑。 ## 2.3 回弹平滑动画 ~~~ class MyViewPager(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) { override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { for(i in 0..childCount){ val childView = getChildAt(i) childView?.layout(i * width, 0, (i + 1) * width, height) } } var scrollStartX = 0f var startX = 0f var index = 0 var scroller: Scroller? = null override fun onTouchEvent(event: MotionEvent?): Boolean { when(event?.action){ MotionEvent.ACTION_DOWN -> { // 起始位置 startX = event.x scrollStartX = event.x } MotionEvent.ACTION_MOVE -> { // 计算视图移动距离,由于是内容移动,刚好相反,所以这里用起始值减去当前值 val offset = (scrollStartX - event.x).toInt() // 判断逻辑offset是否有效,需要屏蔽无效值 val toVal = scrollX + offset if(toVal <= 0){ // 设置为左边界值 scrollTo(0, 0) scrollStartX = 0f } else if(toVal >= (childCount - 1) * width){ // 设置为右边界值 scrollTo((childCount - 1) * width, 0) scrollStartX = ((childCount - 1) * width).toFloat() } else{ // 合法值 scrollBy(offset, 0) scrollStartX = event.x } } MotionEvent.ACTION_UP -> { // 设置回弹效果,也就是如果当前的offset大于width/2 val offset = (startX - event.x).toInt() var isBounds = false if(Math.abs(offset) >= width / 2){ isBounds = true } // 如果需要回弹效果 if(isBounds){ if(offset > 0 && index+1 < childCount){ index++ scroller = Scroller((index - 1) * width + startX - event.x , width - (startX - event.x), 500) } else if(offset < 0 && index-1 >= 0){ index-- scroller = Scroller((index + 1) * width + startX - event.x , -width - (startX - event.x), 500) } invalidate() } else{ // 不需要回弹效果,表示没到一半,也就是需要返回原来的 scroller = Scroller(index * width + startX - event.x, event.x - startX, 500) invalidate() } } } return true } override fun computeScroll() { if(scroller?.isScroll() == true){ scrollTo(scroller!!.currentX.toInt(), 0) invalidate() } } class Scroller(var startX: Float, var offset: Float, var time: Int){ var startTime = 0L var currentX = 0f init { startTime = System.currentTimeMillis() } // 平滑移动imageView fun isScroll(): Boolean{ // 计算速度 val speed = offset / time val distance = (System.currentTimeMillis() - startTime) * speed currentX = startX + distance return Math.abs(distance) <= Math.abs(offset) } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 获取子元素个数 if(childCount == 0) return // 如果是wrap_content,也就是AT_MOST模式 // 如果是match_parents,也就是精确模式 val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) // 自适应,也就是取决于最大的子元素的宽和高,这里直接设置为屏幕的宽和高 if(widthMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels) } } } ~~~ 虽然可以做到回弹,但是对于手指弹起的时候的边界我这里没有处理,默认就有类似于下拉刷新的留白效果。 而且这种方式处理感觉不怎么灵活。所以这里按照视频中的处理,也就是将两个部分分开。分别为处理滑动和处理回弹。 ## 2.4 使用手势识别处理滑动,onTouchEvent处理回弹效果 ~~~ class MyViewPager(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) { // 手势识别器 var gestureDetector: GestureDetector? = null init { gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onScroll( e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float ): Boolean { // X轴移动 if(scrollX + distanceX >= 0 && scrollX + distanceX <= width * (childCount - 1)){ scrollBy(distanceX.toInt(), 0) } return true } }) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { for (i in 0..childCount) { val childView = getChildAt(i) childView?.layout(i * width, 0, (i + 1) * width, height) } } var startX = 0f var index = 0 var scroller: Scroller? = null override fun onTouchEvent(event: MotionEvent?): Boolean { //3.把事件传递给手势识别器 gestureDetector?.onTouchEvent(event) when (event?.action) { MotionEvent.ACTION_DOWN -> { // 起始位置 startX = event.x } MotionEvent.ACTION_MOVE -> { } MotionEvent.ACTION_UP -> { var tempIndex = index if((startX - event.x) > width / 2){ tempIndex++ }else if((event.x - startX) > width / 2 ){ tempIndex-- } // 非法处理 if(tempIndex < 0) tempIndex = 0 if(tempIndex > childCount - 1) tempIndex = childCount - 1 index = tempIndex // 回弹 scrollToPage(index) } } return true } /** * 按照页面下标进行滚动 */ fun scrollToPage(tempIndex: Int){ scroller = Scroller(scrollX.toFloat(), (tempIndex * width - scrollX).toFloat(), 500) invalidate() } override fun computeScroll() { if (scroller?.isScroll() == true) { scrollTo(scroller!!.currentX.toInt(), 0) invalidate() } } class Scroller(var startX: Float, var offset: Float, var time: Int) { var startTime = 0L var currentX = 0f var isFinish = false init { startTime = System.currentTimeMillis() } // 平滑移动imageView fun isScroll(): Boolean { if(isFinish) return false val consumeTime = System.currentTimeMillis() - startTime if(consumeTime < time){ val distance = consumeTime * offset / time currentX = startX + distance } else{ isFinish = true currentX = startX + offset } return true } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 获取子元素个数 if (childCount == 0) return // 如果是wrap_content,也就是AT_MOST模式 // 如果是match_parents,也就是精确模式 val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) // 自适应,也就是取决于最大的子元素的宽和高,这里直接设置为屏幕的宽和高 if (widthMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension( resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels ) } } } ~~~ 需要注意的是,这里的判断不再是用Math.abs(distance) <= Math.abs(offset)来判断,因为计算的时候不可能刚好满足,故而可能会缺失一段距离,也就是ImageView没有吸边。所以需要额外设置,也就是需要多执行一轮。故而引入了一个标志变量isFinish来进行多计算一轮。 而且注意到这种方式更加简单。不用处理复杂的逻辑。且这里的处理逻辑更加清晰: * 根据scrollX获取当前的X轴的位置,也就是移动的起始位置; * 根据计算后的逻辑的下标页面,我们知道最后的X轴的位置; * 那么可以使用最后的X轴的位置减去当前位置,也就是需要移动的offset距离; 且因为在之前处理了 逻辑非法值,故而这里后续不需要处理。 ___ 事实上,系统也提供了一个专门用来滚动的Scroller类,且该类的处理更加平滑,且具有回弹效果。 可以将上面程序改造一下。 ~~~ /** * 使用系统自带Scroller */ class MyViewPager(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) { // 手势识别器 var gestureDetector: GestureDetector? = null var mOnPagerChangerListener: OnPagerChangerListener? = null init { gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onScroll( e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float ): Boolean { // X轴移动 if(scrollX + distanceX >= 0 && scrollX + distanceX <= width * (childCount - 1)){ scrollBy(distanceX.toInt(), 0) } return true } }) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { for (i in 0..childCount) { val childView = getChildAt(i) childView?.layout(i * width, 0, (i + 1) * width, height) } } var startX = 0f var index = 0 // 使用系统android.widget.Scroller var scroller: Scroller = Scroller(context) override fun onTouchEvent(event: MotionEvent?): Boolean { //3.把事件传递给手势识别器 gestureDetector?.onTouchEvent(event) when (event?.action) { MotionEvent.ACTION_DOWN -> { // 起始位置 startX = event.x } MotionEvent.ACTION_MOVE -> { } MotionEvent.ACTION_UP -> { var tempIndex = index if((startX - event.x) > width / 2){ tempIndex++ }else if((event.x - startX) > width / 2 ){ tempIndex-- } // 非法处理 if(tempIndex < 0) tempIndex = 0 if(tempIndex > childCount - 1) tempIndex = childCount - 1 index = tempIndex // 监听接口调用 mOnPagerChangerListener?.onPageChange(index) // 回弹 scrollToPage(index) } } return true } /** * 按照页面下标进行滚动 */ fun scrollToPage(tempIndex: Int){ scroller.startScroll(scrollX, scrollY, tempIndex*width - scrollX, 0) invalidate() } override fun computeScroll() { if (scroller.computeScrollOffset()) { scrollTo(scroller.currX, 0) invalidate() } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 获取子元素个数 if (childCount == 0) return // 如果是wrap_content,也就是AT_MOST模式 // 如果是match_parents,也就是精确模式 val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) // 自适应,也就是取决于最大的子元素的宽和高,这里直接设置为屏幕的宽和高 if (widthMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension( resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels ) } } // 定义一个页面下标改变的监听接口 interface OnPagerChangerListener{ fun onPageChange(position: Int) } } ~~~ 对应的使用单选按钮来做指示器: ~~~ class MainActivity : AppCompatActivity() { val custom_viewpager by lazy { findViewById<MyViewPager>(R.id.custom_viewpager) } val radioGroup by lazy { findViewById<RadioGroup>(R.id.radioGroup) } var imageRes = listOf<Int>(R.drawable.a, R.drawable.b, R.drawable.c) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) for (i in 0 until imageRes.size){ val imageView = ImageView(this) imageView.setBackgroundResource(imageRes.get(i)) custom_viewpager.addView(imageView) val btn = RadioButton(this) if(i == 0) btn.isChecked = true btn.id = i radioGroup.addView(btn) } radioGroup.setOnCheckedChangeListener(object : RadioGroup.OnCheckedChangeListener { override fun onCheckedChanged(group: RadioGroup?, checkedId: Int) { // 切换页面 custom_viewpager.scrollToPage(checkedId) } }) // 页面切换关联radioButton custom_viewpager.mOnPagerChangerListener = object : MyViewPager.OnPagerChangerListener{ override fun onPageChange(position: Int) { radioGroup.check(position) } } } } ~~~ # 3. 注意 但是上面的代码有个Bug,就是对于ViewGroup如果测量没有测量孩子,那么非第一视图的内容都不能显示,也就是对于孩子View没有测量就无法显示,最终会显示白板或者预设置的背景。 所以这里的onMeasure方法需要重写: ~~~ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 获取子元素个数 if (childCount == 0) return // 如果是wrap_content,也就是AT_MOST模式 // 如果是match_parents,也就是精确模式 val widthMode = MeasureSpec.getMode(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) // 自适应,也就是取决于最大的子元素的宽和高,这里直接设置为屏幕的宽和高 var height = MeasureSpec.getSize(heightMeasureSpec) var width = MeasureSpec.getSize(widthMeasureSpec) if (widthMode == MeasureSpec.AT_MOST ){ width = resources.displayMetrics.widthPixels } if(heightMode == MeasureSpec.AT_MOST) { height = resources.displayMetrics.heightPixels } setMeasuredDimension(width, height) // 需要测量孩子 for (i in 0 until childCount){ getChildAt(i).measure(width, height) } } ~~~ 当然这里还是进行了一个粗糙的处理,其实应该测量一下孩子的宽度和高度,然后对应计算一下。