# 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>
~~~
然后就可以做到正常顯示。
:-: 
但是,很明顯這里直接全屏了,而有些時候我們在定義其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);
}
~~~

## 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特效