# 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)
}
}
~~~
当然这里还是进行了一个粗糙的处理,其实应该测量一下孩子的宽度和高度,然后对应计算一下。
- 介绍
- UI
- MaterialButton
- MaterialButtonToggleGroup
- 字体相关设置
- Material Design
- Toolbar
- 下拉刷新
- 可折叠式标题栏
- 悬浮按钮
- 滑动菜单DrawerLayout
- NavigationView
- 可交互提示
- CoordinatorLayout
- 卡片式布局
- 搜索框SearchView
- 自定义View
- 简单封装单选
- RecyclerView
- xml设置点击样式
- adb
- 连接真机
- 小技巧
- 通过字符串ID获取资源
- 自定义View组件
- 使用系统控件重新组合
- 旋转菜单
- 轮播图
- 下拉输入框
- 自定义VIew
- 图片组合的开关按钮
- 自定义ViewPager
- 联系人快速索引案例
- 使用ListView定义侧滑菜单
- 下拉粘黏效果
- 滑动冲突
- 滑动冲突之非同向冲突
- onMeasure
- 绘制字体
- 设置画笔Paint
- 贝赛尔曲线
- Invalidate和PostInvalidate
- super.onTouchEvent(event)?
- setShadowLayer与阴影效果
- Shader
- ImageView的scaleType属性
- 渐变
- LinearGradient
- 图像混合模式
- PorterDuffXfermode
- 橡皮擦效果
- Matrix
- 离屏绘制
- Canvas和图层
- Canvas简介
- Canvas中常用操作总结
- Shape
- 圆角属性
- Android常见动画
- Android动画简介
- View动画
- 自定义View动画
- View动画的特殊使用场景
- LayoutAnimation
- Activity的切换转场效果
- 属性动画
- 帧动画
- 属性动画监听
- 插值器和估值器
- 工具
- dp和px的转换
- 获取屏幕宽高
- JNI
- javah命令
- C和Java相互调用
- WebView
- Android Studio快捷键
- Bitmap和Drawable图像
- Bitmap简要介绍
- 图片缩放和裁剪效果
- 创建指定颜色的Bitmap图像
- Gradle本地仓库
- Gradle小技巧
- RxJava+Okhttp+Retrofit构建网络模块
- 服务器相关配置
- node环境配置
- 3D特效