網站首頁 編程語言 正文
前言
上一篇做了一個滑動折疊的Header控件,主要就是練習了一下滑動事件沖突的問題,控件和文章寫的都不怎么樣。本來想通過這篇文章的控件,整合一下前面六篇文章的內容的,結果寫的太復雜了,就算了,沒有新的技術知識,功能也和之前的安卓廣東選擇控件類似,不過在寫的過程還是有點難度的,用來熟悉自定義view知識還是很不錯的。
需求
這里我也不知道應該怎么描述這個控件,標題里用的大小自動變換的類ViewPager,一開始我把它叫做模仿桌面切換的多頁面切換控件。大致就是和電視那種切換頁面時,中間頁面大,邊上頁面小,切換到中間會有變大的動畫效果,我是覺得這樣的控件和炫酷。
核心思想如下:
1、類似viewpager,但同時顯示兩種頁面,中間為主頁面,左右為小頁面,小頁面大小一樣,間距排列
2、左右滑動可以將切換頁面,超過頁面數量大小不能滑動,滑動停止主界面能自動移動到目標位置
效果圖
編寫代碼
這里代碼寫的還是挺簡單的,沒有用到ViewPager那樣的Adapter,也沒有處理預加載問題,滑動起來不是特別流暢,頁面放置到頂層時切換很突兀,但是還是達到了一開始的設計要求吧!
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import androidx.core.animation.addListener
import androidx.core.view.children
import com.silencefly96.module_common.R
import java.util.*
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.roundToInt
/**
* @author silence
* @date 2022-10-20
*/
class DesktopLayerLayout @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attributeSet, defStyleAttr) {
companion object{
// 方向
const val ORIENTATION_VERTICAL = 0
const val ORIENTATION_HORIZONTAL = 1
// 狀態
const val SCROLL_STATE_IDLE = 0
const val SCROLL_STATE_DRAGGING = 1
const val SCROLL_STATE_SETTLING = 2
// 默認padding值
const val DEFAULT_PADDING_VALUE = 50
// 豎向默認主界面比例
const val DEFAULT_MAIN_PERCENT_VERTICAL = 0.8f
// 橫向默認主界面比例
const val DEFAULT_MAIN_PERCENT_HORIZONTAL = 0.6f
// 其他頁面相對主界面頁面最小的縮小比例
const val DEFAULT_OTHER_VIEW_SCAN_SIZE = 0.5f
}
/**
* 當前主頁面的index
*/
@Suppress("MemberVisibilityCanBePrivate")
var curIndex = 0
// 由于將view提高層級會搞亂順序,需要記錄原始位置信息
private var mInitViews = ArrayList<View>()
// view之間的間距
private var mGateLength = 0
// 滑動距離
private var mDxLen = 0f
// 系統最小移動距離
private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
// 控件狀態
private var mState = SCROLL_STATE_IDLE
// 當前設置的屬性動畫
private var mValueAnimator: ValueAnimator? = null
// 實際布局的左右坐標值
private var mRealLeft = 0
private var mRealRight = 0
// 上一次按下的橫豎坐標
private var mLastX = 0f
// 方向,從XML內獲得
private var mOrientation: Int
// 是否對屏幕方向自適應,從XML內獲得
private val isAutoFitOrientation: Boolean
// padding,從XML內獲得,如果左右移動,則上下要有padding,但左右沒有padding
private val mPaddingValue: Int
// 豎向主內容比例,從XML內獲得,剩余兩邊平分
private val mMainPercentVertical: Float
// 橫向主內容比例,從XML內獲得,剩余兩邊平分
private val mMainPercentHorizontal: Float
// 其他頁面相對主界面頁面最小的縮小比例
private val mOtherViewScanMinSize: Float
init {
// 獲取XML參數
val typedArray =
context.obtainStyledAttributes(attributeSet, R.styleable.DesktopLayerLayout)
mOrientation = typedArray.getInteger(R.styleable.DesktopLayerLayout_mOrientation,
ORIENTATION_VERTICAL)
isAutoFitOrientation =
typedArray.getBoolean(R.styleable.DesktopLayerLayout_isAutoFitOrientation, true)
mPaddingValue = typedArray.getInteger(R.styleable.DesktopLayerLayout_mPaddingValue,
DEFAULT_PADDING_VALUE)
mMainPercentVertical =
typedArray.getFraction(R.styleable.DesktopLayerLayout_mMainPercentVertical,
1, 1, DEFAULT_MAIN_PERCENT_VERTICAL)
mMainPercentHorizontal =
typedArray.getFraction(R.styleable.DesktopLayerLayout_mMainPercentHorizontal,
1, 1, DEFAULT_MAIN_PERCENT_HORIZONTAL)
mOtherViewScanMinSize =
typedArray.getFraction(R.styleable.DesktopLayerLayout_mOtherViewScanMinSize,
1, 1, DEFAULT_OTHER_VIEW_SCAN_SIZE)
typedArray.recycle()
}
override fun onFinishInflate() {
super.onFinishInflate()
// 獲得所有xml內的view,保留原始順序
mInitViews.addAll(children)
}
// 屏幕方向變化并不會觸發,初始時會觸發,自適應
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// Log.e("TAG", "onSizeChanged: w=$w, h=$h")
// 根據屏幕變化修改方向,自適應
if (isAutoFitOrientation) {
mOrientation = if (w > h) ORIENTATION_HORIZONTAL else ORIENTATION_VERTICAL
requestLayout()
}
}
// 需要在manifest中注冊捕捉事件類型,android:configChanges="orientation|keyboardHidden|screenSize"
public override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if(newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
mOrientation = ORIENTATION_VERTICAL
requestLayout()
}else if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
mOrientation = ORIENTATION_HORIZONTAL
requestLayout()
}
}
// 排列規則:初始化第一個放中間,其他向右排列,中間最大,中心在左右邊上的最小,不可見的也是最小
// view的大小應該只和它在可見頁面的位置有關,不應該和curIndex有關,是充分不必要關系
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 獲取默認尺寸,考慮背景大小
val width = max(getDefaultSize(0, widthMeasureSpec), suggestedMinimumWidth)
val height = max(getDefaultSize(0, heightMeasureSpec), suggestedMinimumHeight)
// 設置間距
mGateLength = width / 4
// 中間 view 大小
val maxWidth: Int
val maxHeight: Int
// 不同方向尺寸不同
if (mOrientation == ORIENTATION_HORIZONTAL) {
maxWidth = (width * mMainPercentHorizontal).toInt()
maxHeight = height - 2 * mPaddingValue
}else {
maxWidth = (width * mMainPercentVertical).toInt()
maxHeight = height - 2 * mPaddingValue
}
// 兩側 view 大小,第三排
val minWidth = (maxWidth * mOtherViewScanMinSize).toInt()
val minHeight = (maxHeight * mOtherViewScanMinSize).toInt()
var childWidth: Int
var childHeight: Int
for (i in 0 until childCount) {
val child = mInitViews[i]
val scanSize = getViewScanSize(i, scrollX)
childWidth = minWidth + ((maxWidth - minWidth) * scanSize).toInt()
childHeight = minHeight + ((maxHeight - minHeight) * scanSize).toInt()
// Log.e("TAG", "onMeasure($i): childWidth=$childWidth, childHeight=$childHeight")
child.measure(MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY))
}
setMeasuredDimension(width, height)
}
// 選中view為最大,可見部分會縮放,不可見部分和第三排一樣大
private fun getViewScanSize(index: Int, scrolledLen: Int): Float {
var scanSize = 0f
// 開始時當前view未測量,不計算
if (measuredWidth == 0) return scanSize
// 初始化的時候,第一個放中間,所以index移到可見范圍為[2+index, index-2],可見!=可移動
val scrollLeftLimit = (index - 2) * mGateLength
val scrollRightLimit = (index + 2) * mGateLength
// 先判斷child是否可見
if (scrolledLen in scrollLeftLimit..scrollRightLimit) {
// 根據二次函數計算比例
scanSize = scanByParabola(scrollLeftLimit, scrollRightLimit, scrolledLen).toFloat()
}
return scanSize
}
// 根據拋物線計算比例,y屬于[0, 1]
// 映射關系:(form, 0) ((from + to) / 2, 0) (to, 0) -> (0, 0) (1, 1) (2, 0)
@Suppress("SameParameterValue")
private fun scanByParabola(from: Int, to: Int, cur: Int): Double {
// 公式:val y = 1 - (x - 1).toDouble().pow(2.0)
// Log.e("TAG", "scanByParabola:from=$from, to=$to, cur=$cur ")
val x = ((cur - from) / (to - from).toFloat() * 2).toDouble()
return 1 - (x - 1).pow(2.0)
}
// layout 按順序間距排列即可,大小有onMeasure控制,開始位置在中心,也和curIndex無關
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val startX = (r + l) / 2
// 排列布局
for (i in 0 until childCount) {
val child = mInitViews[i]
// 中間減去間距,再減去一半的寬度,得到左邊坐標
val left = startX + mGateLength * i - child.measuredWidth / 2
val top = (b + t) / 2 - child.measuredHeight / 2
val right = left + child.measuredWidth
val bottom = top + child.measuredHeight
// Log.e("TAG", "onLayout($i): left=$left, right=$right")
child.layout(left, top, right, bottom)
}
// 修改大小,布局完成后移動
scrollBy(mDxLen.toInt(), 0)
mDxLen = 0f
// 完成布局及移動后,繪制之前,將可見view提高層級
val targetIndex = getCurrentIndex()
for (i in 2 downTo 0) {
val preIndex = targetIndex - i
val aftIndex = targetIndex + i
// 逐次提高層級,注意在mInitViews拿就可以,不可見不管
if (preIndex in 0..childCount) {
bringChildToFront(mInitViews[preIndex])
}
if (aftIndex != preIndex && aftIndex in 0 until childCount) {
bringChildToFront(mInitViews[aftIndex])
}
}
}
// 根據滾動距離獲得當前index
private fun getCurrentIndex()= (scrollX / mGateLength.toFloat()).roundToInt()
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
ev?.let {
when(it.action) {
MotionEvent.ACTION_DOWN -> {
mLastX = ev.x
if(mState == SCROLL_STATE_IDLE) {
mState = SCROLL_STATE_DRAGGING
}else if (mState == SCROLL_STATE_SETTLING) {
mState = SCROLL_STATE_DRAGGING
// 去除結束監聽,結束動畫
mValueAnimator?.removeAllListeners()
mValueAnimator?.cancel()
}
}
MotionEvent.ACTION_MOVE -> {
// 若ACTION_DOWN是本view攔截,則下面代碼不會觸發,要在onTouchEvent判斷
val dX = mLastX - ev.x
return checkScrollInView(scrollX + dX)
}
MotionEvent.ACTION_UP -> {}
}
}
return super.onInterceptHoverEvent(ev)
}
// 根據可以滾動的范圍,計算是否可以滾動
private fun checkScrollInView(length : Float): Boolean {
// 一層情況
if (childCount <= 1) return false
// 左右兩邊最大移動值,即把最后一個移到中間
val leftScrollLimit = 0
val rightScrollLimit = (childCount - 1) * mGateLength
return (length >= leftScrollLimit && length <= rightScrollLimit)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev: MotionEvent?): Boolean {
ev?.let {
when(it.action) {
// 防止點擊空白位置或者子view未處理touch事件
MotionEvent.ACTION_DOWN -> return true
MotionEvent.ACTION_MOVE -> {
// 如果是本view攔截的ACTION_DOWN,要在此判斷
val dX = mLastX - ev.x
if(checkScrollInView(scrollX + dX)) {
move(ev)
}
}
MotionEvent.ACTION_UP -> moveUp()
}
}
return super.onTouchEvent(ev)
}
private fun move(ev: MotionEvent) {
val dX = mLastX - ev.x
// 修改mScrollLength,重新measure及layout,再onLayout的最后實現移動
mDxLen += dX
if(abs(mDxLen) >= mTouchSlop) {
requestLayout()
}
// 更新值
mLastX = ev.x
}
private fun moveUp() {
// 賦值
val targetScrollLen = getCurrentIndex() * mGateLength
// 不能使用scroller,無法在移動的時候進行測量
// mScroller.startScroll(scrollX, scrollY, (targetScrollLen - scrollX), 0)
// 這里使用ValueAnimator處理剩余的距離,模擬滑動到需要的位置
val animator = ValueAnimator.ofFloat(scrollX.toFloat(), targetScrollLen.toFloat())
animator.addUpdateListener { animation ->
// Log.e("TAG", "stopMove: " + animation.animatedValue as Float)
mDxLen = animation.animatedValue as Float - scrollX
requestLayout()
}
// 在動畫結束時修改curIndex
animator.addListener (onEnd = {
curIndex = getCurrentIndex()
mState = SCROLL_STATE_IDLE
})
// 設置狀態
mState = SCROLL_STATE_SETTLING
animator.duration = 300L
animator.start()
}
}
desktop_layer_layout_style.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name ="DesktopLayerLayout"> <attr name="mOrientation"> <enum name ="vertical" value="0" /> <enum name ="horizontal" value="1" /> </attr> <attr name="isAutoFitOrientation" format="boolean"/> <attr name="mPaddingValue" format="integer"/> <attr name="mMainPercentVertical" format="fraction"/> <attr name="mMainPercentHorizontal" format="fraction"/> <attr name="mOtherViewScanMinSize" format="fraction"/> </declare-styleable> </resources>
主要問題
這里用到的知識之前六篇文章都已經講過了,主要就是有幾點實現起來復雜了一些,下面講講。
頁面的自動縮放
講解頁面的縮放之前,需要先將一下頁面的擺放。這里以四分之一為間距來擺放來自XML的view,第一個view放在中間,其他都在其右邊按順序排列。
所以頁面的縮放,只和view的位置有關,而view的位置又只和當前控件左右滑動的距離有關,變量就是當前控件橫坐標上的滑動值scrollX。根據view的原始index可以得到每個view可見時的滑動值范圍,在通過這個范圍和實際的滑動值scrollX,進行映射換算得到其縮放比例。這里用到了拋物線進行換算:
// 公式:y = 1 - (x - 1).toDouble().pow(2.0)
// 映射關系:(form, 0) ((from + to) / 2, 0) (to, 0) -> (0, 0) (1, 1) (2, 0)
滑動范圍的限定
滑動范圍的限定和上面類似,邊界就是第一個或者最后一個view移動到正中間的范圍,只要實際的滑動值scrollX在這個范圍內,那滑動就是有效的。
頁面層級提升與恢復
頁面層級的提升在我之前文章:手撕安卓側滑欄也有用到,就是自己把view放到children的最后去,實際上ViewGroup提供了類似的功能:bringChildToFront,但是原理是一樣的。
@Override
public void bringChildToFront(View child) {
final int index = indexOfChild(child);
if (index >= 0) {
removeFromArray(index);
addInArray(child, mChildrenCount);
child.mParent = this;
requestLayout();
invalidate();
}
}
這里的提升view不止一個了,而且后面還要恢復,即不能打亂children的順序。所以我在onFinishInflate中用一個數組保存下這些子view的原始順序,使用的時候用這個數組就行,children里面的順序不用管,只要讓需要顯示的view放在最后就行。我這里因為間距是四分之一的寬度,最多可以顯示五個view,所以在onLayout的最后將這五個view得到,并按順序放到children的最后。
onDraw探討
這里我還想對onDraw探討一下,一開始我以為既然onMeasure、onLayout中都需要去調用child的measure和layout,那能不能在onDraw里面自己去繪制child,不用自帶的,結果發現這是不行的。onDraw實際是View里面的一個空方法,實際對頁面的繪制是在控件的draw方法中,那重寫draw方法自己去繪制child呢?實際也不行,當把draw方法里面的super.draw時提示報錯:
也就是說必須繼承super.draw這個方法,點開源碼發現,super.draw已經把child繪制了,而且onDraw方法也是從里面傳出來的。所以沒辦法,乖乖用bringChildToFront放到children最后去,來提升層級吧,不然也不會提供這一個方法來是不是?
原文鏈接:https://blog.csdn.net/lfq88/article/details/127532126
相關推薦
- 2022-01-12 錯誤:UnmappedTerms cannot be cast to org.elasticsear
- 2023-12-18 Mybatisplus的增刪改查
- 2022-07-27 Python中的協程(Coroutine)操作模塊(greenlet、gevent)_python
- 2022-08-03 python數據類型可變與不可變深入分析_python
- 2022-11-21 C++?getcwd函數獲取項目運行路徑方法詳解_C 語言
- 2022-02-26 Android操作SQLite基本用法_Android
- 2022-10-14 composer -vvv 命令
- 2022-05-29 ASP.NET?Core在WebApi項目中使用Cookie_實用技巧
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支