Android自定義ViewGroup嵌套與交互實(shí)現(xiàn)幕布全屏滾動(dòng)
自定義 ViewGroup 全屏選中效果
事情是這個(gè)樣子的,前幾天產(chǎn)品丟給我一個(gè)視頻,你覺得這個(gè)效果怎么樣?我們的 App 也做一個(gè)這個(gè)效果吧!
我當(dāng)時(shí)的反應(yīng):不行,不能,不可以!?。?/p>
開什么玩笑!就沒見過這么玩的,這不是坑人嗎?
此時(shí)產(chǎn)品幽幽的回了一句,“別人都能做,你怎么不能做,并且iOS說可以做,還很簡(jiǎn)單。”
我心里一萬個(gè)不信,糟老頭子太壞了,想騙我?
我立馬和iOS同事統(tǒng)一戰(zhàn)線,說不能做,實(shí)現(xiàn)不了吧。結(jié)果iOS同事幽幽的說了一句 “已經(jīng)做了,四行代碼完成”。
我勒個(gè)去,就指著我卷是吧。
這也沒辦法了,群里問問大神有什么好的方案,“xdm,車先減個(gè)速,(圖片)這個(gè)效果怎么實(shí)現(xiàn)?”
“做不了...”
“讓產(chǎn)品滾...”
“沒做過,也沒見過...”
“性能不好,不推薦,換方案吧。”
“GridView嵌套ScrollView , 要不RV嵌套R(shí)V?...”
“不理他,繼續(xù)開車...”
...群里技術(shù)氛圍果然沒有讓我失望,哎,看來還是得靠自己,抬頭望了望天天,扣了扣腦闊,無語啊。
好了,說了這么多玩笑話,回歸正題,其實(shí)關(guān)于標(biāo)題的這種效果,確實(shí)是對(duì)性能的開銷更大,且網(wǎng)上相關(guān)開源的項(xiàng)目也幾乎沒找到。
到底怎么做呢?相信跟著我一起復(fù)習(xí)的小伙伴們心里都有了一點(diǎn)雛形。自定義ViewGroup。
下面跟著我一起再次鞏固一次 ViewGroup 的測(cè)量與布局,加上事件的處理,就能完成對(duì)應(yīng)的功能。
話不多說,Let's go
一、布局的測(cè)量與布局
首先GridView嵌套ScrollView,RV 嵌套 RV 什么的,就寬度就限制死了,其次滾動(dòng)方向也固定死了,不好做。
肯定是選用自定義 ViewGroup 的方案,自己測(cè)量,自己布局,自己實(shí)現(xiàn)滾動(dòng)與縮放邏輯。
從產(chǎn)品發(fā)的競(jìng)品App的視頻來看,我們需要先明確三個(gè)變量,一行顯示多少個(gè)Item、垂直距離每一個(gè)Item的間距,水平距離每一個(gè)Item的間距。
然后我們測(cè)量每一個(gè)ItemView的寬度,每一個(gè)Item的寬度加起來就是ViewGroup的寬度,每一個(gè)Item的高度加起來就是ViewGroup的高度。
我們目前先不限定Item的寬高,先試著測(cè)量一下:
class CurtainViewContrainer extends ViewGroup { private int horizontalSpacing = 20; //每一個(gè)Item的左右間距 private int verticalSpacing = 20; //每一個(gè)Item的上下間距 private int mRowCount = 6; // 一行多少個(gè)Item private Adapter mAdapter; public CurtainViewContrainer(Context context) { this(context, null); } public CurtainViewContrainer(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CurtainViewContrainer(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { setClipChildren(false); setClipToPadding(false); } @SuppressLint("DrawAllocation") @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final int sizeWidth = MeasureSpec.getSize(widthMeasureSpec) - this.getPaddingRight() - this.getPaddingLeft(); final int modeWidth = MeasureSpec.getMode(widthMeasureSpec); final int sizeHeight = MeasureSpec.getSize(heightMeasureSpec) - this.getPaddingTop() - this.getPaddingBottom(); final int modeHeight = MeasureSpec.getMode(heightMeasureSpec); int childCount = getChildCount(); if (mAdapter == null || mAdapter.getItemCount() == 0 || childCount == 0) { setMeasuredDimension(sizeWidth, 0); return; } int curCount = 1; int totalControlHeight = 0; int totalControlWidth = 0; int layoutChildViewCurX = this.getPaddingLeft(); int curRow = 0; int curColumn = 0; SparseArray<Integer> rowWidth = new SparseArray<>(); //全部行的寬度 //開始遍歷 for (int i = 0; i < childCount; i++) { View childView = getChildAt(i); int row = curCount / mRowCount; //當(dāng)前子View是第幾行 int column = curCount % mRowCount; //當(dāng)前子View是第幾列 //測(cè)量每一個(gè)子View寬度 measureChild(childView, widthMeasureSpec, heightMeasureSpec); int width = childView.getMeasuredWidth(); int height = childView.getMeasuredHeight(); boolean isLast = (curCount + 1) % mRowCount == 0; if (row == curRow) { layoutChildViewCurX += width + horizontalSpacing; totalControlWidth += width + horizontalSpacing; rowWidth.put(row, totalControlWidth); } else { //已經(jīng)換行了 layoutChildViewCurX = this.getPaddingLeft(); totalControlWidth = width + horizontalSpacing; rowWidth.put(row, totalControlWidth); //添加高度 totalControlHeight += height + verticalSpacing; } //最多只擺放9個(gè) curCount++; curRow = row; curColumn = column; } //循環(huán)結(jié)束之后開始計(jì)算真正的寬度 List<Integer> widthList = new ArrayList<>(rowWidth.size()); for (int i = 0; i < rowWidth.size(); i++) { Integer integer = rowWidth.get(i); widthList.add(integer); } Integer maxWidth = Collections.max(widthList); setMeasuredDimension(maxWidth, totalControlHeight); }
當(dāng)遇到高度不統(tǒng)一的情況下,就會(huì)遇到問題,所以我們記錄一下每一行的最高高度,用于計(jì)算控件的測(cè)量高度。
雖然這樣測(cè)量是沒有問題的,但是布局還是有坑,姑且先這么測(cè)量:
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childCount = getChildCount(); int curCount = 1; int layoutChildViewCurX = l; int layoutChildViewCurY = t; int curRow = 0; int curColumn = 0; SparseArray<Integer> rowWidth = new SparseArray<>(); //全部行的寬度 //開始遍歷 for (int i = 0; i < childCount; i++) { View childView = getChildAt(i); int row = curCount / mRowCount; //當(dāng)前子View是第幾行 int column = curCount % mRowCount; //當(dāng)前子View是第幾列 //每一個(gè)子View寬度 int width = childView.getMeasuredWidth(); int height = childView.getMeasuredHeight(); childView.layout(layoutChildViewCurX, layoutChildViewCurY, layoutChildViewCurX + width, layoutChildViewCurY + height); if (row == curRow) { //同一行 layoutChildViewCurX += width + horizontalSpacing; } else { //換行了 layoutChildViewCurX = l; layoutChildViewCurY += height + verticalSpacing; } //最多只擺放9個(gè) curCount++; curRow = row; curColumn = column; } performBindData(); }
這樣做并沒有緊挨著頭上的Item,目前我們把Item的寬高都使用同樣的大小,是勉強(qiáng)能看的,一旦高度不統(tǒng)一,就不能看了。
先不管那么多,先固定大小顯示出來看看效果。
反正是能看了,一個(gè)寨版的 GridView ,但是超出了寬度的限制。接下來我們先做事件的處理,讓他動(dòng)起來。
二、全屏滾動(dòng)邏輯
首先我們需要把顯示的 ViewGroup 控件封裝為一個(gè)類,讓此ViewGroup在另一個(gè)ViewGroup內(nèi)部移動(dòng),不然還能讓內(nèi)部的每一個(gè)子View單獨(dú)移動(dòng)嗎?肯定是整體一起移動(dòng)更方便一點(diǎn)。
然后我們觸摸容器 ViewGroup 中控制子 ViewGroup 移動(dòng)即可,那怎么移動(dòng)呢?
我知道,用 MotionEvent + Scroller 就可以滾動(dòng)啦!
可以!又不可以,Scroller確實(shí)是可以動(dòng)起來,但是在我們拖動(dòng)與縮放之后,不能影響到內(nèi)部的點(diǎn)擊事件。
那可以不可以用 ViewDragHelper 來實(shí)現(xiàn)動(dòng)作效果?
也不行,雖然 ViewDragHelper 是ViewGroup專門用于移動(dòng)的幫助類,但是它內(nèi)部其實(shí)還是封裝的 MotionEvent + Scroller。
而 Scroller 為什么不行?
這種效果我們不能使用 Canvas 的移動(dòng),不能使用 Sroller 去移動(dòng),因?yàn)樗鼈儾荒苡涗浺苿?dòng)后的 View 變化矩陣,我們需要使用基本的 setTranslation 來實(shí)現(xiàn),自己控制矩陣的變化從而控制整個(gè)視圖樹。
我們把觸摸的攔截與事件的處理放到一個(gè)公用的事件處理類中:
public class TouchEventHandler { private static final float MAX_SCALE = 1.5f; //最大能縮放值 private static final float MIN_SCALE = 0.8f; //最小能縮放值 //當(dāng)前的觸摸事件類型 private static final int TOUCH_MODE_UNSET = -1; private static final int TOUCH_MODE_RELEASE = 0; private static final int TOUCH_MODE_SINGLE = 1; private static final int TOUCH_MODE_DOUBLE = 2; private View mView; private int mode = 0; private float scaleFactor = 1.0f; private float scaleBaseR; private GestureDetector mGestureDetector; private float mTouchSlop; private MotionEvent preMovingTouchEvent = null; private MotionEvent preInterceptTouchEvent = null; private boolean mIsMoving; private float minScale = MIN_SCALE; private FlingAnimation flingY = null; private FlingAnimation flingX = null; private ViewBox layoutLocationInParent = new ViewBox(); //移動(dòng)中不斷變化的盒模型 private final ViewBox viewportBox = new ViewBox(); //初始化的盒模型 private PointF preFocusCenter = new PointF(); private PointF postFocusCenter = new PointF(); private PointF preTranslate = new PointF(); private float preScaleFactor = 1f; private final DynamicAnimation.OnAnimationUpdateListener flingAnimateListener; private boolean isKeepInViewport = false; private TouchEventListener controlListener = null; private int scalePercentOnlyForControlListener = 0; public TouchEventHandler(Context context, View view) { this.mView = view; flingAnimateListener = (animation, value, velocity) -> keepWithinBoundaries(); mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { flingX = new FlingAnimation(mView, DynamicAnimation.TRANSLATION_X); flingX.setStartVelocity(velocityX) .addUpdateListener(flingAnimateListener) .start(); flingY = new FlingAnimation(mView, DynamicAnimation.TRANSLATION_Y); flingY.setStartVelocity(velocityY) .addUpdateListener(flingAnimateListener) .start(); return false; } }); ViewConfiguration vc = ViewConfiguration.get(view.getContext()); mTouchSlop = vc.getScaledTouchSlop() * 0.8f; } /** * 設(shè)置內(nèi)部布局視圖窗口高度和寬度 */ public void setViewport(int winWidth, int winHeight) { viewportBox.setValues(0, 0, winWidth, winHeight); } /** * 暴露的方法,內(nèi)部處理事件并判斷是否攔截事件 */ public boolean detectInterceptTouchEvent(MotionEvent event) { final int action = event.getAction() & MotionEvent.ACTION_MASK; onTouchEvent(event); if (action == MotionEvent.ACTION_DOWN) { preInterceptTouchEvent = MotionEvent.obtain(event); mIsMoving = false; } if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { mIsMoving = false; } if (action == MotionEvent.ACTION_MOVE && mTouchSlop < calculateMoveDistance(event, preInterceptTouchEvent)) { mIsMoving = true; } return mIsMoving; } /** * 當(dāng)前事件的真正處理邏輯 */ public boolean onTouchEvent(MotionEvent event) { mGestureDetector.onTouchEvent(event); int action = event.getAction() & MotionEvent.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: mode = TOUCH_MODE_SINGLE; preMovingTouchEvent = MotionEvent.obtain(event); if (flingX != null) { flingX.cancel(); } if (flingY != null) { flingY.cancel(); } break; case MotionEvent.ACTION_UP: mode = TOUCH_MODE_RELEASE; break; case MotionEvent.ACTION_POINTER_UP: case MotionEvent.ACTION_CANCEL: mode = TOUCH_MODE_UNSET; break; case MotionEvent.ACTION_POINTER_DOWN: mode++; if (mode >= TOUCH_MODE_DOUBLE) { scaleFactor = preScaleFactor = mView.getScaleX(); preTranslate.set(mView.getTranslationX(), mView.getTranslationY()); scaleBaseR = (float) distanceBetweenFingers(event); centerPointBetweenFingers(event, preFocusCenter); centerPointBetweenFingers(event, postFocusCenter); } break; case MotionEvent.ACTION_MOVE: if (mode >= TOUCH_MODE_DOUBLE) { //雙指縮放 float scaleNewR = (float) distanceBetweenFingers(event); centerPointBetweenFingers(event, postFocusCenter); if (scaleBaseR <= 0) { break; } scaleFactor = (scaleNewR / scaleBaseR) * preScaleFactor * 0.15f + scaleFactor * 0.85f; int scaleState = TouchEventListener.FREE_SCALE; float finalMinScale = isKeepInViewport ? minScale : minScale * 0.8f; if (scaleFactor >= MAX_SCALE) { scaleFactor = MAX_SCALE; scaleState = TouchEventListener.MAX_SCALE; } else if (scaleFactor <= finalMinScale) { scaleFactor = finalMinScale; scaleState = TouchEventListener.MIN_SCALE; } if (controlListener != null) { int current = (int) (scaleFactor * 100); //回調(diào) if (scalePercentOnlyForControlListener != current) { scalePercentOnlyForControlListener = current; controlListener.onScaling(scaleState, scalePercentOnlyForControlListener); } } mView.setPivotX(0); mView.setPivotY(0); mView.setScaleX(scaleFactor); mView.setScaleY(scaleFactor); float tx = postFocusCenter.x - (preFocusCenter.x - preTranslate.x) * scaleFactor / preScaleFactor; float ty = postFocusCenter.y - (preFocusCenter.y - preTranslate.y) * scaleFactor / preScaleFactor; mView.setTranslationX(tx); mView.setTranslationY(ty); keepWithinBoundaries(); } else if (mode == TOUCH_MODE_SINGLE) { //單指移動(dòng) float deltaX = event.getRawX() - preMovingTouchEvent.getRawX(); float deltaY = event.getRawY() - preMovingTouchEvent.getRawY(); onSinglePointMoving(deltaX, deltaY); } break; case MotionEvent.ACTION_OUTSIDE: //外界的事件 break; } preMovingTouchEvent = MotionEvent.obtain(event); return true; } /** * 計(jì)算兩個(gè)事件的移動(dòng)距離 */ private float calculateMoveDistance(MotionEvent event1, MotionEvent event2) { if (event1 == null || event2 == null) { return 0f; } float disX = Math.abs(event1.getRawX() - event2.getRawX()); float disY = Math.abs(event1.getRawX() - event2.getRawX()); return (float) Math.sqrt(disX * disX + disY * disY); } /** * 單指移動(dòng) */ private void onSinglePointMoving(float deltaX, float deltaY) { float translationX = mView.getTranslationX() + deltaX; mView.setTranslationX(translationX); float translationY = mView.getTranslationY() + deltaY; mView.setTranslationY(translationY); keepWithinBoundaries(); } /** * 需要保持在界限之內(nèi) */ private void keepWithinBoundaries() { //默認(rèn)不在界限內(nèi),不做限制,直接返回 if (!isKeepInViewport) { return; } calculateBound(); int dBottom = layoutLocationInParent.bottom - viewportBox.bottom; int dTop = layoutLocationInParent.top - viewportBox.top; int dLeft = layoutLocationInParent.left - viewportBox.left; int dRight = layoutLocationInParent.right - viewportBox.right; float translationX = mView.getTranslationX(); float translationY = mView.getTranslationY(); //邊界限制 if (dLeft > 0) { mView.setTranslationX(translationX - dLeft); } if (dRight < 0) { mView.setTranslationX(translationX - dRight); } if (dBottom < 0) { mView.setTranslationY(translationY - dBottom); } if (dTop > 0) { mView.setTranslationY(translationY - dTop); } } /** * 移動(dòng)時(shí)計(jì)算邊界,賦值給本地的視圖 */ private void calculateBound() { View v = mView; float left = v.getLeft() * v.getScaleX() + v.getTranslationX(); float top = v.getTop() * v.getScaleY() + v.getTranslationY(); float right = v.getRight() * v.getScaleX() + v.getTranslationX(); float bottom = v.getBottom() * v.getScaleY() + v.getTranslationY(); layoutLocationInParent.setValues((int) top, (int) left, (int) right, (int) bottom); } /** * 計(jì)算兩個(gè)手指之間的距離 */ private double distanceBetweenFingers(MotionEvent event) { if (event.getPointerCount() > 1) { float disX = Math.abs(event.getX(0) - event.getX(1)); float disY = Math.abs(event.getY(0) - event.getY(1)); return Math.sqrt(disX * disX + disY * disY); } return 1; } /** * 計(jì)算兩個(gè)手指之間的中心點(diǎn) */ private void centerPointBetweenFingers(MotionEvent event, PointF point) { float xPoint0 = event.getX(0); float yPoint0 = event.getY(0); float xPoint1 = event.getX(1); float yPoint1 = event.getY(1); point.set((xPoint0 + xPoint1) / 2f, (yPoint0 + yPoint1) / 2f); } /** * 設(shè)置視圖是否要保持在窗口中 */ public void setKeepInViewport(boolean keepInViewport) { isKeepInViewport = keepInViewport; } /** * 設(shè)置控制的監(jiān)聽回調(diào) */ public void setControlListener(TouchEventListener controlListener) { this.controlListener = controlListener; } }
由于內(nèi)部封裝了移動(dòng)與縮放的處理,所以我們只需要在事件容器內(nèi)部調(diào)用這個(gè)方法即可:
public class CurtainLayout extends FrameLayout { private final TouchEventHandler mGestureHandler; private CurtainViewContrainer mCurtainViewContrainer; private boolean disallowIntercept = false; public CurtainLayout(@NonNull Context context) { this(context, null); } public CurtainLayout(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public CurtainLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); setClipChildren(false); setClipToPadding(false); mCurtainViewContrainer = new CurtainViewContrainer(getContext()); addView(mCurtainViewContrainer); mGestureHandler = new TouchEventHandler(getContext(), mCurtainViewContrainer); //設(shè)置是否在窗口內(nèi)移動(dòng) mGestureHandler.setKeepInViewport(false); } @Override public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { super.requestDisallowInterceptTouchEvent(disallowIntercept); this.disallowIntercept = disallowIntercept; } @Override public boolean onInterceptTouchEvent(MotionEvent event) { return (!disallowIntercept && mGestureHandler.detectInterceptTouchEvent(event)) || super.onInterceptTouchEvent(event); } @Override public boolean onTouchEvent(MotionEvent event) { return !disallowIntercept && mGestureHandler.onTouchEvent(event); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { mGestureHandler.setViewport(w, h); } }
對(duì)于一些復(fù)雜的處理都做了相關(guān)的注釋,接下來看看加了事件處理之后的效果:
已經(jīng)可以自由拖動(dòng)與縮放了,但是目前的測(cè)量與布局是有問題的,加下來我們抽取與優(yōu)化一下。
三、抽取Adapter與LayoutManager
首先,內(nèi)部的子View肯定是不能直接寫在 xml 中的,太不優(yōu)雅了,加下來我們定義一個(gè)Adapter,用于填充數(shù)據(jù),順便做一個(gè)多類型的布局。
public abstract class CurtainAdapter { //返回總共子View的數(shù)量 public abstract int getItemCount(); //根據(jù)索引創(chuàng)建不同的布局類型,如果都是一樣的布局則不需要重寫 public int getItemViewType(int position) { return 0; } //根據(jù)類型創(chuàng)建對(duì)應(yīng)的View布局 public abstract View onCreateItemView(@NonNull Context context, @NonNull ViewGroup parent, int itemType); //可以根據(jù)類型或索引綁定數(shù)據(jù) public abstract void onBindItemView(@NonNull View itemView, int itemType, int position); }
然后就是在繪制布局中通過設(shè)置 Apdater 來實(shí)現(xiàn)布局的添加與綁定邏輯。
public void setAdapter(CurtainAdapter adapter) { mAdapter = adapter; inflateAllViews(); } public CurtainAdapter getAdapter() { return mAdapter; } //填充Adapter布局 private void inflateAllViews() { removeAllViewsInLayout(); if (mAdapter == null || mAdapter.getItemCount() == 0) { return; } //添加布局 for (int i = 0; i < mAdapter.getItemCount(); i++) { int itemType = mAdapter.getItemViewType(i); View view = mAdapter.onCreateItemView(getContext(), this, itemType); addView(view); } requestLayout(); } //綁定布局中的數(shù)據(jù) private void performBindData() { if (mAdapter == null || mAdapter.getItemCount() == 0) { return; } post(() -> { for (int i = 0; i < mAdapter.getItemCount(); i++) { int itemType = mAdapter.getItemViewType(i); View view = getChildAt(i); mAdapter.onBindItemView(view, itemType, i); } }); }
當(dāng)然需要在指定的地方調(diào)用了,測(cè)量與布局中都需要處理。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int childCount = getChildCount(); if (mAdapter == null || mAdapter.getItemCount() == 0 || childCount == 0) { setMeasuredDimension(0, 0); return; } ... } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if (mAdapter == null || mAdapter.getItemCount() == 0) { return; } performLayout(); performBindData(); }
接下來的重點(diǎn)就是我們對(duì)布局的方式進(jìn)行抽象化,最簡(jiǎn)單的肯定是上面這種寬高固定的,如果是垂直的排列,我們?cè)O(shè)置一個(gè)垂直的瀑布流管理器,設(shè)置寬度固定,高度自適應(yīng),如果寬度不固定,那么是無法到達(dá)瀑布流的效果的。
同理對(duì)另一種水平排列的瀑布流我們?cè)O(shè)置高度固定,寬度自適應(yīng)。
所以必須要設(shè)置 LayoutManager,如果不設(shè)置就拋異常。
接下來就是 LayoutManager 的接口與具體調(diào)用:
public interface ILayoutManager { public static final int DIRECTION_VERITICAL = 0; public static final int DIRECTION_HORIZONTAL = 1; public abstract int[] performMeasure(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedValue); public abstract void performLayout(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedValue); public abstract int getLayoutDirection(); }
有了接口之后我們就可以先寫調(diào)用了:
class CurtainViewContrainer extends ViewGroup { private ILayoutManager mLayoutManager; private int horizontalSpacing = 20; //每一個(gè)Item的左右間距 private int verticalSpacing = 20; //每一個(gè)Item的上下間距 private int mRowCount = 6; // 一行多少個(gè)Item private int fixedWidth = CommUtils.dip2px(150); //如果是垂直瀑布流,需要設(shè)置寬度固定 private int fixedHeight = CommUtils.dip2px(180); //先寫死,后期在抽取屬性 private CurtainAdapter mAdapter; @SuppressLint("DrawAllocation") @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int childCount = getChildCount(); if (mAdapter == null || mAdapter.getItemCount() == 0 || childCount == 0) { setMeasuredDimension(0, 0); return; } measureChildren(widthMeasureSpec, heightMeasureSpec); if (mLayoutManager != null && (fixedWidth > 0 || fixedHeight > 0)) { for (int i = 0; i < childCount; i++) { View childView = getChildAt(i); if (mLayoutManager.getLayoutDirection() == ILayoutManager.DIRECTION_VERITICAL) { measureChild(childView, MeasureSpec.makeMeasureSpec(fixedWidth, MeasureSpec.EXACTLY), heightMeasureSpec); } else { measureChild(childView, widthMeasureSpec, MeasureSpec.makeMeasureSpec(fixedHeight, MeasureSpec.EXACTLY)); } } int[] dimensions = mLayoutManager.performMeasure(this, mRowCount, horizontalSpacing, verticalSpacing, mLayoutManager.getLayoutDirection() == ILayoutManager.DIRECTION_VERITICAL ? fixedWidth : fixedHeight); setMeasuredDimension(dimensions[0], dimensions[1]); } else { throw new RuntimeException("You need to set the layoutManager first"); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if (mAdapter == null || mAdapter.getItemCount() == 0) { return; } if (mLayoutManager != null && (fixedWidth > 0 || fixedHeight > 0)) { mLayoutManager.performLayout(this, mRowCount, horizontalSpacing, verticalSpacing, mLayoutManager.getLayoutDirection() == ILayoutManager.DIRECTION_VERITICAL ? fixedWidth : fixedHeight); performBindData(); } else { throw new RuntimeException("You need to set the layoutManager first"); } }
那么我們先來水平的LayoutManager,相對(duì)簡(jiǎn)單一些,看看如何具體實(shí)現(xiàn):
public class HorizontalLayoutManager implements ILayoutManager { @Override public int[] performMeasure(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedHeight) { int childCount = viewGroup.getChildCount(); int curCount = 0; int totalControlHeight = 0; int totalControlWidth = 0; int curRow = 0; SparseArray<Integer> rowTotalWidth = new SparseArray<>(); //每一行的總寬度 //開始遍歷 for (int i = 0; i < childCount; i++) { View childView = viewGroup.getChildAt(i); int row = curCount / rowCount; //當(dāng)前子View是第幾行 //已經(jīng)測(cè)量過了,直接取寬高 int width = childView.getMeasuredWidth(); if (row == curRow) { //當(dāng)前行 totalControlWidth += width + horizontalSpacing; } else { //換行了 totalControlWidth = width + horizontalSpacing; } rowTotalWidth.put(row, totalControlWidth); //賦值 curCount++; curRow = row; } //循環(huán)結(jié)束之后開始計(jì)算真正的寬高 totalControlHeight = (rowCount * (fixedHeight + verticalSpacing)) - verticalSpacing + viewGroup.getPaddingTop() + viewGroup.getPaddingBottom(); List<Integer> widthList = new ArrayList<>(); for (int i = 0; i < rowTotalWidth.size(); i++) { Integer width = rowTotalWidth.get(i); widthList.add(width); } totalControlWidth = Collections.max(widthList); rowTotalWidth.clear(); rowTotalWidth = null; return new int[]{totalControlWidth - horizontalSpacing, totalControlHeight - verticalSpacing}; } @Override public void performLayout(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedHeight) { int childCount = viewGroup.getChildCount(); int curCount = 1; int layoutChildViewCurX = viewGroup.getPaddingLeft(); int layoutChildViewCurY = viewGroup.getPaddingTop(); int curRow = 0; //開始遍歷 for (int i = 0; i < childCount; i++) { View childView = viewGroup.getChildAt(i); int row = curCount / rowCount; //當(dāng)前子View是第幾行 //每一個(gè)子View寬度 int width = childView.getMeasuredWidth(); childView.layout(layoutChildViewCurX, layoutChildViewCurY, layoutChildViewCurX + width, layoutChildViewCurY + fixedHeight); if (row == curRow) { //同一行 layoutChildViewCurX += width + horizontalSpacing; } else { //換行了 layoutChildViewCurX = childView.getPaddingLeft(); layoutChildViewCurY += fixedHeight + verticalSpacing; } //賦值 curCount++; curRow = row; } } @Override public int getLayoutDirection() { return DIRECTION_HORIZONTAL; } }
對(duì)于水平的布局方式來說,高度是固定的,我們很容易的就能計(jì)算出來,但是寬度每一行的可能都不一樣,我們用一個(gè)List記錄每一行的總寬度,在最后設(shè)置的時(shí)候取出最大的一行作為容器的寬度,記得要減去一個(gè)間距哦。
那么不同寬度的水平布局方式效果的實(shí)現(xiàn)就是這樣:
實(shí)現(xiàn)是實(shí)現(xiàn)了,但是這么計(jì)算是不是有問題?每一行的最高高度好像不是太準(zhǔn)確,如果每一列都有一個(gè)最大高度,但是不是同一列,那么測(cè)量的高度就比實(shí)際高度要更高。
加一個(gè)灰色背景就可以看到效果:
我們?cè)賰?yōu)化一下,它應(yīng)該是計(jì)算每一列的總共高度,然后選出最大高度才對(duì):
@Override public int[] performMeasure(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedWidth) { int childCount = viewGroup.getChildCount(); int curPosition = 0; int totalControlHeight = 0; int totalControlWidth = 0; SparseArray<List<Integer>> columnAllHeight = new SparseArray<>(); //每一列的全部高度 //開始遍歷 for (int i = 0; i < childCount; i++) { View childView = viewGroup.getChildAt(i); int row = curPosition / rowCount; //當(dāng)前子View是第幾行 int column = curPosition % rowCount; //當(dāng)前子View是第幾列 //已經(jīng)測(cè)量過了,直接取寬高 int height = childView.getMeasuredHeight(); List<Integer> integers = columnAllHeight.get(column); if (integers == null || integers.isEmpty()) { integers = new ArrayList<>(); } integers.add(height + verticalSpacing); columnAllHeight.put(column, integers); //賦值 curPosition++; } //循環(huán)結(jié)束之后開始計(jì)算真正的寬高 totalControlWidth = (rowCount * (fixedWidth + horizontalSpacing) + viewGroup.getPaddingLeft() + viewGroup.getPaddingRight()); List<Integer> totalHeights = new ArrayList<>(); for (int i = 0; i < columnAllHeight.size(); i++) { List<Integer> heights = columnAllHeight.get(i); int totalHeight = 0; for (int j = 0; j < heights.size(); j++) { totalHeight += heights.get(j); } totalHeights.add(totalHeight); } totalControlHeight = Collections.max(totalHeights); columnAllHeight.clear(); columnAllHeight = null; return new int[]{totalControlWidth - horizontalSpacing, totalControlHeight - verticalSpacing}; }
再看看效果:
寬高真正的測(cè)量準(zhǔn)確之后我們接下來就開始屬性的抽取與封裝了。
四、自定義屬性
我們先前都是使用的成員變量來控制一些間距與邏輯的觸發(fā),這就跟業(yè)務(wù)耦合了,如果想做到通用的一個(gè)效果,肯定還是要抽取自定義屬性,做到對(duì)應(yīng)的配置開關(guān),就可以適應(yīng)更多的場(chǎng)景使用,也是開源項(xiàng)目的必備技能。
細(xì)數(shù)一下我們需要控制的屬性:
- enableScale 是否支持縮放
- maxScale 縮放的最大比例
- minScale 縮放的最小比例
- moveInViewport 是否只能在布局內(nèi)部移動(dòng)
- horizontalSpacing item的水平間距
- verticalSpacing item的垂直間距
- fixed_width 豎向的排列 - 寬度定死 并設(shè)置對(duì)應(yīng)的LayoutManager
- fixed_height 橫向的排列 - 高度定死 并設(shè)置對(duì)應(yīng)的LayoutManager
定義屬性如下:
<!-- 全屏幕布布局自定義屬性 --> <declare-styleable name="CurtainLayout"> <!--Item的橫向間距--> <attr name="horizontalSpacing" format="dimension" /> <!--Item的垂直間距--> <attr name="verticalSpacing" format="dimension" /> <!--每行需要展示多少數(shù)量的Item--> <attr name="rowCount" format="integer" /> <!--垂直方向瀑布流布局,固定寬度為多少--> <attr name="fixedWidth" format="dimension" /> <!--水平方向瀑布流布局,固定高度為多少--> <attr name="fixedHeight" format="dimension" /> <!--是否只能在布局內(nèi)部移動(dòng) 當(dāng)為false時(shí)候?yàn)樽杂梢苿?dòng)--> <attr name="moveInViewport" format="boolean" /> <!--是否可以縮放--> <attr name="enableScale" format="boolean" /> <!--最大與最小的縮放比例--> <attr name="maxScale" format="float" /> <attr name="minScale" format="float" /> </declare-styleable>
取出屬性并對(duì)容器布局與觸摸處理器做賦值的操作:
public class CurtainLayout extends FrameLayout { private int horizontalSpacing; private int verticalSpacing; private int rowCount; private int fixedWidth; private int fixedHeight; private boolean moveInViewport; private boolean enableScale; private float maxScale; private float minScale; public CurtainLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); setClipChildren(false); setClipToPadding(false); mCurtainViewContrainer = new CurtainViewContrainer(getContext()); addView(mCurtainViewContrainer); initAttr(context, attrs); mGestureHandler = new TouchEventHandler(getContext(), mCurtainViewContrainer); //設(shè)置是否在窗口內(nèi)移動(dòng) mGestureHandler.setKeepInViewport(moveInViewport); mGestureHandler.setEnableScale(enableScale); mGestureHandler.setMinScale(minScale); mGestureHandler.setMaxScale(maxScale); mCurtainViewContrainer.setHorizontalSpacing(horizontalSpacing); mCurtainViewContrainer.setVerticalSpacing(verticalSpacing); mCurtainViewContrainer.setRowCount(rowCount); mCurtainViewContrainer.setFixedWidth(fixedWidth); mCurtainViewContrainer.setFixedHeight(fixedHeight); if (fixedWidth > 0 || fixedHeight > 0) { if (fixedWidth > 0) { mCurtainViewContrainer.setLayoutDirectionVertical(fixedWidth); } else { mCurtainViewContrainer.setLayoutDirectionHorizontal(fixedHeight); } } } /** * 獲取自定義屬性 */ private void initAttr(Context context, AttributeSet attrs) { TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.CurtainLayout); this.horizontalSpacing = mTypedArray.getDimensionPixelSize(R.styleable.CurtainLayout_horizontalSpacing, 20); this.verticalSpacing = mTypedArray.getDimensionPixelSize(R.styleable.CurtainLayout_verticalSpacing, 20); this.rowCount = mTypedArray.getInteger(R.styleable.CurtainLayout_rowCount, 6); this.fixedWidth = mTypedArray.getDimensionPixelOffset(R.styleable.CurtainLayout_fixedWidth, 150); this.fixedHeight = mTypedArray.getDimensionPixelSize(R.styleable.CurtainLayout_fixedHeight, 180); this.moveInViewport = mTypedArray.getBoolean(R.styleable.CurtainLayout_moveInViewport, false); this.enableScale = mTypedArray.getBoolean(R.styleable.CurtainLayout_enableScale, true); this.minScale = mTypedArray.getFloat(R.styleable.CurtainLayout_minScale, 0.7f); this.maxScale = mTypedArray.getFloat(R.styleable.CurtainLayout_maxScale, 1.5f); mTypedArray.recycle(); } ... public void setMoveInViewportInViewport(boolean moveInViewport) { this.moveInViewport = moveInViewport; mGestureHandler.setKeepInViewport(moveInViewport); } public void setEnableScale(boolean enableScale) { this.enableScale = enableScale; mGestureHandler.setEnableScale(enableScale); } public void setMinScale(float minScale) { this.minScale = minScale; mGestureHandler.setMinScale(minScale); } public void setMaxScale(float maxScale) { this.maxScale = maxScale; mGestureHandler.setMaxScale(maxScale); } public void setHorizontalSpacing(int horizontalSpacing) { mCurtainViewContrainer.setHorizontalSpacing(horizontalSpacing); } public void setVerticalSpacing(int verticalSpacing) { mCurtainViewContrainer.setVerticalSpacing(verticalSpacing); } public void setRowCount(int rowCount) { mCurtainViewContrainer.setRowCount(rowCount); } public void setFixedWidth(int fixedWidth) { mCurtainViewContrainer.setLayoutDirectionVertical(fixedWidth); } public void setFixedHeight(int fixedHeight) { mCurtainViewContrainer.setLayoutDirectionHorizontal(fixedHeight); }
然后在布局容器與事件處理類中做對(duì)應(yīng)的賦值操作即可。
如何使用?
<CurtainLayout android:id="@+id/curtain_view" android:layout_width="match_parent" android:layout_height="match_parent" app:enableScale="true" app:fixedWidth="150dp" app:horizontalSpacing="10dp" app:maxScale="1.5" app:minScale="0.8" app:moveInViewport="true" app:rowCount="6" app:verticalSpacing="10dp"> </CurtainLayout>
如果在xml中設(shè)置過 fixedWidth 或者 fixedHeight ,那么在 Activity 中也可以不設(shè)置 LayoutManager 了。
val list = listOf<String>( ... ) val adapter = Viewgroup6Adapter(list) val curtainView = findViewById<CurtainLayout>(R.id.curtain_view) curtainView.adapter = adapter
最終效果:
后記
關(guān)于 ViewGroup 的測(cè)量與布局與事件,我們已經(jīng)從易到難復(fù)習(xí)了四期了,相信同學(xué)應(yīng)該是能掌握了。
話說到里就應(yīng)該到了完結(jié)時(shí)刻,關(guān)于自定義View與自定義ViewGroup的復(fù)習(xí)與回顧就到此告一段落了,對(duì)于市面上能見到的一些布局效果,基本上能通過自定義ViewGroup與自定義View來實(shí)現(xiàn)。其實(shí)很早就想完結(jié)了,因?yàn)楦杏X這些東西有一點(diǎn)過于基礎(chǔ)了,好像大家都不是很有興趣看這些基礎(chǔ)的東西,
自定義View可以很方便的做自定義的繪制與本身與內(nèi)部的一些移動(dòng),而對(duì)于一些多View移動(dòng)的特效,我們就算用自定義View難以實(shí)現(xiàn)或?qū)崿F(xiàn)的比較復(fù)雜的話,也能使用Behivor或者M(jìn)otionLayot 來實(shí)現(xiàn),當(dāng)然這就是另一個(gè)篇章了。
如果有興趣也可以看看我之前的 Behivor 文章 【傳送門】 或者 MotionLayot 的文章,【傳送門】。
同時(shí)也可以搜索與翻看之前的文章哦。
本文的代碼均可以在我的Kotlin測(cè)試項(xiàng)目中看到,【傳送門】。你也可以關(guān)注我的這個(gè)Kotlin項(xiàng)目,我有時(shí)間都會(huì)持續(xù)更新。
關(guān)于本文的全屏滑動(dòng)效果,我也會(huì)開源傳到 MavenCentral 供大家依賴使用,【傳送門】
使用:Gradle中直接依賴即可:
implementation "com.gitee.newki123456:curtain_layout:1.0.0"
好了,如果類似的效果有更多的更好的其他方式,也希望大家能評(píng)論區(qū)交流一下。
慣例,我如有講解不到位或錯(cuò)漏的地方,希望同學(xué)們可以指出。
哎,找圖片都找了接近一個(gè)小時(shí),如果大家想要對(duì)應(yīng)的圖片也可以去項(xiàng)目中拿哦!????
以上就是Android自定義ViewGroup嵌套與交互實(shí)現(xiàn)幕布全屏滾動(dòng)的詳細(xì)內(nèi)容,更多關(guān)于Android ViewGroup全屏滾動(dòng)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android中自定義Window Title樣式實(shí)例
這篇文章主要介紹了Android中自定義Window Title樣式實(shí)例,本文給出效果預(yù)覽和實(shí)現(xiàn)方法,需要的朋友可以參考下2015-01-01android中SharedPreferences實(shí)現(xiàn)存儲(chǔ)用戶名功能
本篇文章主要介紹了android中SharedPreferences實(shí)現(xiàn)保存用戶名功能,詳細(xì)的介紹了SharedPreferences的功能,需要的朋友可以參考下2017-04-04Android實(shí)現(xiàn)環(huán)形進(jìn)度條
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)環(huán)形進(jìn)度條,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-07-07Android實(shí)現(xiàn)簡(jiǎn)單實(shí)用的垂直進(jìn)度條
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)簡(jiǎn)單實(shí)用的垂直進(jìn)度條,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-07-07Android使用Notification實(shí)現(xiàn)通知功能
這篇文章主要為大家詳細(xì)介紹了Android使用Notification實(shí)現(xiàn)通知功能,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-11-11Android中ListView Item布局優(yōu)化技巧
這篇文章主要介紹了Android中ListView Item布局優(yōu)化技巧,以實(shí)例形式分析了ListView Item布局的相關(guān)實(shí)現(xiàn)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-10-10Android launcher中模擬按home鍵的實(shí)現(xiàn)
這篇文章主要介紹了Android launcher中模擬按home鍵的實(shí)現(xiàn)的相關(guān)資料,需要的朋友可以參考下2017-05-05