Android中不規則形狀View的佈局實現
Android中不規則形狀View的佈局實現
在Android中不管是View還是ViewGroup,都是方的! 方的! 方的!
而對於非方形的,Android官方並沒有的非常好的解決方案.有的無非就是自定義View了.
然而自定義View非常麻煩,需要重寫很多方法,而且稍微不注意可能就會喪失一些特性或者造成一些Bug.
而且即便是自定義View,其實那個自定義View還是方的!!!,自定義View所能做的也就是繪製非方的圖形,但是其觸控區域還是方的,如果需要讓一些區域觸控無效,需要在onTouchEvent中嚴謹的計算,而這只是僅僅針對View而言,如果這個View是ViewGroup,則需要重寫dispatchTouchEvent,dispatchToucEvent的邏輯相比於onTouchEvent的處理邏輯複雜多了.
而此時此刻,ClipPathLayout孕育而生,非常好的解決了這個問題.
何為ClipPathLayout,顧名思義,這就是一個可以對子View的Path進行裁剪的佈局.
那麼這個佈局有什麼作用呢?
問的好,這個佈局可以對其子View的繪製範圍和觸控範圍進行裁剪,進而實現不規則形狀的View.
光說有啥用.
那就亮出來給你們看看效果.
效果展示
將方形圖片裁剪成圓形並且讓圓形View的4角不接收觸控事件

image
很多遊戲都會有方向鍵,曾經我也做過一個小遊戲,但是在做方向鍵的時候遇到一個問題,4個方向按鈕的位置會有重疊,導致區域性地方會發生誤差.
當時沒有特別好的解決辦法,只能做自定義View,而自定義View特別麻煩,需要重寫onTouchEvent和onDraw計算落點屬於哪個方向,並增加點選效果.
簡單的自定義View會喪失很多Android自帶的一些特性,要支援這些特性又繁瑣而複雜.
下面藉助於CLipPathLayout用4個菱形按鈕實現的方向控制鍵很好的解決了這個問題

image
對於遙控器的按鍵的模擬同樣有上述問題,一般只能採用自定義View實現,較為繁瑣.
以下是藉助於ClipPathLayout實現的遙控器按鈕,由於沒有美工切圖,比較醜,將就下吧

image
甚至我們可以將不連續的圖形變成一個View,比如做一個陰陽魚的按鈕

image
使用
效果展示完了,那麼如何使用呢?使用麻煩也是白搭啊.
那麼接下來就講下如何使用.
新增依賴
庫已經上傳jcenter,Android Studio自帶jcenter依賴,
如果沒有新增,請在專案根build.gradle中新增jcenter Maven
buildscript { repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:3.1.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { google() jcenter() } }
在app module中的build.gradle中新增依賴
implementation 'com.yxf:clippathlayout:1.0.+'
其實ClipPathLayout只是一個介面,大部分的ViewGroup,實現這個介面都可以實現對不規則圖形的佈局,並且保留父類ViewGroup的特性.
當前實現了三個不規則圖形的佈局,分別是
- ClipPathFrameLayout
- ClipPathLinearLayout
- ClipPathRelativeLayout
如果有其他佈局要求,請自定義,參見
那麼父佈局要如何知道其子View應該是何形狀呢?那必然需要給子View做自定義屬性吧,很顯然去重寫子View新增自定義屬性是不合理的.那麼久採用外部關聯的方式好了.還有一個問題,什麼屬性可以定義各種各樣的形狀呢?思來想去怕是也只有閉合的Path了吧,嗯,沒錯,就是藉助於Path,並且讓子View和這個Path關聯,然後把這些資訊告訴父佈局,這樣父佈局才知道應該如何去控制這個子View的形狀.
光說理論有什麼用,來點實際的啊!
好,那就來點實際的.這裡以最簡單的圓形View為例.
在一個實現了ClipPathLayout介面的ViewGroup(以ClipPathFrameLayout為例)中新增一個子View(ImageView).
<com.yxf.clippathlayout.impl.ClipPathFrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/clip_path_frame_layout" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/image" android:layout_width="300dp" android:layout_height="300dp" android:layout_gravity="center" android:src="@mipmap/image" /> </com.yxf.clippathlayout.impl.ClipPathFrameLayout>
mImageView = mLayout.findViewById(R.id.image);
然後構建一個PathInfo物件
new PathInfo.Builder(new CirclePathGenerator(), mImageView) .setApplyFlag(mApplyFlag) .setClipType(mClipType) .create() .apply();
搞定!執行就可以看到一個圓形的View.

image
和效果展示上的這個圖差不多,不過這張圖多了幾個按鈕,然後那個圓形View有個綠色背景,那個是用來做對比的,在那個View之下添加了一個綠色的View,不要在意這些細節......
對其中使用到的引數和方法做下說明
PathInfo.Builder
PathInfo建立器,用於配置和生成PathInfo.
構造方法定義如下
/** * @param generator Path生成器 * @param view 實現了ClipPathLayout介面的ViewGroup的子View */ public Builder(PathGenerator generator, View view) { }
PathGenerator
CirclePathGenerator是一個PathGenerator介面的實現類,用於生成圓形的Path.
PathGenerator定義如下
public interface PathGenerator { /** * @param old 以前使用過的Path,如果以前為null,則可能為null * @param view Path關聯的子View物件 * @param width 生成Path所限定的範圍寬度,一般是子View寬度 * @param height 生成Path所限定的範圍高度,一般是子View高度 * @return 返回一個Path物件,必須為閉合的Path,將用於裁剪子View * * 其中Path的範圍即left : 0 , top : 0 , right : width , bottom : height */ Path generatePath(Path old, View view, int width, int height); }
PathGenerator是使用的核心,父佈局將根據這個來對子View進行裁剪來實現不規則圖形.
此庫內建了4種Path生成器
- CirclePathGenerator(圓形Path生成器)
- OvalPathGenerator(橢圓Path生成器)
- RhombusPathGenerator(菱形Path生成器)
- OvalRingPathGenerator(橢圓環Path生成器)
如果有其他複雜的Path,可以自己實現PathGenerator,可以參考示例中的陰陽魚Path的生成.
ApplyFlag
Path的應用標誌,有如下幾種
- APPLY_FLAG_DRAW_ONLY(只用於繪製)
- APPLY_FLAG_TOUCH_ONLY(只用於觸控事件)
- APPLY_FLAG_DRAW_AND_TOUCH(繪製和觸控事件一起應用)
預設不設定的話是APPLY_FLAG_DRAW_AND_TOUCH.
切換效果如下

image
ClipType
Path的裁剪模式,有如下兩種
- CLIP_TYPE_IN(取Path內範圍作為不規則圖形子View)
- CLIP_TYPE_OUT(取Path外範圍作為不規則圖形子View)
預設不設定為CLIP_TYPE_IN.
切換效果如下

image
自定義ClipPathLayout
只有三種父佈局是不是有點坑?萬一我要用ConstraintLayout呢?那豈不是涼涼.
沒有ConstraintLayout這都被你發現了.由於ConstraintLayout並不存在於系統標準庫中,而存在於支援庫中,為了減少不必要的引用,讓庫擁有良好的獨立性,故而沒有實現(其實是因為懶...).
好了,其實也可以自己實現了,也是很簡單的操作.
自定義一個ClipPathLayout很簡單,首先選擇一個ViewGroup,然後實現ClipPathLayout介面.
然後再在自定義的ViewGroup中建立一個ClipPathLayoutDelegate物件.
ClipPathLayoutDelegate mClipPathLayoutDelegate = new ClipPathLayoutDelegate(this);
並將所有ClipPathLayout介面的實現都委派給ClipPathLayoutDelegate去實現.
這裡需要注意兩點:
- 需要重寫ViewGroup的drawChild,按如下實現即可
@Override protected boolean drawChild(Canvas canvas, View child, long drawingTime) { beforeDrawChild(canvas, child, drawingTime); boolean result = super.drawChild(canvas, child, drawingTime); afterDrawChild(canvas, child, drawingTime); return result; }
- requestLayout方法也需要重寫,這屬於ViewGroup和ClipPathLayout共有的方法,這個方法會在父類的ViewGroup的構造方法中呼叫,在父類構造方法被呼叫時,mClipPathLayoutDelegate還沒有初始化,如果直接呼叫會報空指標,所以需要新增空判斷.
@Override public void requestLayout() { super.requestLayout(); // the request layout method would be invoked in the constructor of super class if (mClipPathLayoutDelegate == null) { return; } mClipPathLayoutDelegate.requestLayout(); }
這裡將整個ClipPathFrameLayout原始碼貼出作為參考
public class ClipPathFrameLayout extends FrameLayout implements ClipPathLayout { ClipPathLayoutDelegate mClipPathLayoutDelegate = new ClipPathLayoutDelegate(this); public ClipPathFrameLayout(@NonNull Context context) { this(context, null); } public ClipPathFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public ClipPathFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public boolean isTransformedTouchPointInView(float x, float y, View child, PointF outLocalPoint) { return mClipPathLayoutDelegate.isTransformedTouchPointInView(x, y, child, outLocalPoint); } @Override public void applyPathInfo(PathInfo info) { mClipPathLayoutDelegate.applyPathInfo(info); } @Override public void cancelPathInfo(View child) { mClipPathLayoutDelegate.cancelPathInfo(child); } @Override public void beforeDrawChild(Canvas canvas, View child, long drawingTime) { mClipPathLayoutDelegate.beforeDrawChild(canvas, child, drawingTime); } @Override public void afterDrawChild(Canvas canvas, View child, long drawingTime) { mClipPathLayoutDelegate.afterDrawChild(canvas, child, drawingTime); } //the drawChild method is not belong to ClipPathLayout , //but you should rewrite it without changing the return value of the method @Override protected boolean drawChild(Canvas canvas, View child, long drawingTime) { beforeDrawChild(canvas, child, drawingTime); boolean result = super.drawChild(canvas, child, drawingTime); afterDrawChild(canvas, child, drawingTime); return result; } //do not forget to rewrite the method @Override public void requestLayout() { super.requestLayout(); // the request layout method would be invoked in the constructor of super class if (mClipPathLayoutDelegate == null) { return; } mClipPathLayoutDelegate.requestLayout(); } @Override public void notifyPathChanged(View child) { mClipPathLayoutDelegate.notifyPathChanged(child); } @Override public void notifyAllPathChanged() { mClipPathLayoutDelegate.notifyAllPathChanged(); } }
原理實現
看完了使用,有沒有覺得非常之簡單,簡單是必須得.
那麼想不想了解下原理呢?
不想!
不,我知道,你想!
既然你誠心誠意的想知道,那麼我就大發慈悲的告訴你.
故事說來話長,且聽我慢慢道來,很久很久以前,有這樣一位少年,這位少年苦修Android,立志要在Android上做一個貪吃蛇遊戲,然後這位少年,終於神功有成,開始寫起了他的貪吃蛇遊戲.
然而,當他寫著寫著,他居然終於寫出來了.
操,點的按鍵明明是上鍵怎麼沒有效果,log怎麼列印是左鍵!!!
少年心中有一萬匹草泥馬在心中奔騰.
然後少年開始分析,這是為什麼,老天爺為什麼要這樣對他.
哇,居然讓他分析出來了......
原來少年的方向按鍵是這個樣子的(原諒我沒有特別好的作圖工具,將就下吧)

image
很明顯,這4個方向鍵有很多重合的地方,重合的地方就會有一個問題,在重合的地方只有上面的View收得到觸控事件.那麼少年的問題就是觸控到了重合的地方導致的.
當時少年很鬱悶啊,網上找了很久,都沒有解決這個問題.然後只好用自定義View的方式,將4個方向鍵做成一個自定義View.問題也算解決了,但是自定義View很麻煩,也不完美,這在少年心裡一直是個疙瘩.
前段時間少年不小心給老闆發了一張圖片

image
然後這位少年意外的獲得了自由,在獲得自由後,少年想起來了久久不能平靜的疙瘩.
少年決定一定要讓這個疙瘩平靜下去,於是少年開始了他新的腦細胞死亡之路.
少年很快的想到了Path這個可以實現不規則圖形的關鍵點,但是要如何應用這個Path呢?
應用從兩個方面考慮,一個是繪製,一個是觸控事件.
繪製
先說繪製,繪製的過程比較簡單,查閱下原始碼無非就是以下兩種情況
型別 | 過程 |
---|---|
View | draw -> onDraw |
ViewGroup | draw ->dispatchDraw -> drawChild -> child.draw |
draw是final方法沒法重寫,沒戲.View的onDraw,難道每個View都要重寫嗎?那怕不是石樂志.那麼只能是diapatchDraw和drawChild了,dispatchDraw邏輯複雜,drawChild很簡單.很自然的重寫drawChild了.
protected boolean drawChild(Canvas canvas, View child, long drawingTime) { return child.draw(canvas, this, drawingTime); }
drawChild的實現非常簡單,這是一個非常好的劫持繪製過程的時機.
少年想到只要在這裡將Canvas根據Path進行裁剪,那麼不管子View如何繪製,被裁剪掉的部分都不會顯示,這樣說不定還能減少過度繪製的問題.
然後少年修改了drawChild方法
@Override protected boolean drawChild(Canvas canvas, View child, long drawingTime) { beforeDrawChild(canvas, child, drawingTime); boolean result = super.drawChild(canvas, child, drawingTime); afterDrawChild(canvas, child, drawingTime); return result; } @Override public void beforeDrawChild(Canvas canvas, View child, long drawingTime) { canvas.save(); canvas.translate(child.getLeft(), child.getTop()); if (hasLayoutRequest) { hasLayoutRequest = false; notifyAllPathChangedInternal(false); } ViewGetKey key = getTempViewGetKey(child.hashCode(), child); PathInfo info = mPathInfoMap.get(key); if (info != null) { if ((info.getApplyFlag() & PathInfo.APPLY_FLAG_DRAW_ONLY) != 0) { Path path = info.getPath(); if (path != null) { Utils.clipPath(canvas, path, info.getClipType()); } else { Log.d(TAG, "beforeDrawChild: path is null , hash code : " + info.hashCode()); } } } resetTempViewGetKey(); canvas.translate(-child.getLeft(), -child.getTop()); } @Override public void afterDrawChild(Canvas canvas, View child, long drawingTime) { canvas.restore(); }
少年成功的劫持了Canvas,然後通過Canvas.clipPath對Canvas進行裁剪,將裁剪後的Canvas再交給子View處理,完美!
觸控
至於觸控事件,那就麻煩了,麻煩到炸了好吧.如何應用到Path到觸控事件呢?重寫dispatchTouchEvent嗎?當少年開啟ViewGroup的原始碼,看到200多行,裡面還摻雜著各種hide,各種private的方法和成員變數時,少年秒慫了.
但是前段時間知乎大佬出了一個巢狀滑動的庫 NestedTouchScrollingLayout 給了少年一些靈感,幹嘛不直接把onInterceptTouchEvent返回true,然後在onTouchEvent裡重寫做事件分發呢?哇好像可以耶.但是少年又想了想,如果直接攔截,自己又重寫onTouchEvent,這樣子和直接重寫dispatchTouchEvent真的有區別嗎?在onTouchEvent裡寫直接讓原來dispatchTouchEvent的邏輯廢了,還增加了一段流程,可能還會喪失很多特性,製造一些bug,而且onInterceptTouchEvent和onTouchEvent這兩個方法將被佔用,後續繼承的子View可能不能很好的重寫.當然直接廢棄掉原生程式碼,自己寫一些簡單的操作確實是可行的,但是作為一個有追求的少年,這樣做疙瘩是得不到平靜的.為了讓疙瘩平靜下來,少年開始尋找dispatchTouchEvent中有沒有可以見縫插針的地方.
終於少年找到了這樣一段程式碼
//................................... if (!canViewReceivePointerEvents(child) || !isTransformedTouchPointInView(x, y, child, null)) { ev.setTargetAccessibilityFocus(false); continue; } //................................... if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { //............................... }
其中canViewReceivePointerEvents是判斷子View是否有資格接收點選事件的;isTransformedTouchPointInView是判斷觸控點是否在View中的;而dispatchTransformedTouchEvent,就是判斷是否攔截事件或者分發給子View的地方.
少年的想法是對View根據Path進行裁剪實現不規則形狀的View.那麼如果能在isTransformedTouchPointInView中判斷是否在Path內,則可以實現讓不在Path內的點的流程直接continue掉,從而不走dispatchTransformedTouchEvent.
找到一個非常好的想法,少年非常激動.然後點進去isTransformedTouchPointInView方法被潑了一身冷水.
/** * Returns true if a child view contains the specified point when transformed * into its coordinate space. * Child must not be null. * @hide */ protected boolean isTransformedTouchPointInView(float x, float y, View child, PointF outLocalPoint) { final float[] point = getTempPoint(); point[0] = x; point[1] = y; transformPointToViewLocal(point, child); final boolean isInView = child.pointInView(point[0], point[1]); if (isInView && outLocalPoint != null) { outLocalPoint.set(point[0], point[1]); } return isInView; }
這個方法居然是hide的!!!!!少年有句mmp當時就講了.過了一會少年心情稍微平靜下來,等等,hide的方法只是不能呼叫,但是沒定義不能重寫啊,而且這個方法是protected的,完全具備重寫條件.少年又有了激情.
少年繼續跟蹤裡面的transformPointToViewLocal方法
/** * @hide */ public void transformPointToViewLocal(float[] point, View child) { point[0] += mScrollX - child.mLeft; point[1] += mScrollY - child.mTop; if (!child.hasIdentityMatrix()) { child.getInverseMatrix().mapPoints(point); } }
mmp,這又是一個hide方法,但是這下需要的就不是重寫而是呼叫了........那麼用反射呼叫嗎?反射會降低效能啊,Android p又禁反射了,而且各個版本系統程式碼不一樣,還不一定有這個方法,呵呵呵,還真被少年猜中了,Android4.4的原始碼中沒有這個方法............谷歌,少年一口鹽汽水噴死你!
既然沒有辦法呼叫就想想替代方案唄,瞭解下這個方法幹嘛的,不用看都知道,這個方法是將點座標通過View變幻的逆矩陣映射回去看點是否在View內.很容易重寫嘛,然而谷歌爸爸會讓你這麼簡單成功嗎?naive!
/** * Utility method to retrieve the inverse of the current mMatrix property. * We cache the matrix to avoid recalculating it when transform properties * have not changed. * * @return The inverse of the current matrix of this view. * @hide */ public final Matrix getInverseMatrix() { ensureTransformationInfo(); if (mTransformationInfo.mInverseMatrix == null) { mTransformationInfo.mInverseMatrix = new Matrix(); } final Matrix matrix = mTransformationInfo.mInverseMatrix; mRenderNode.getInverseMatrix(matrix); return matrix; }
View的getInverseMatrix方法是hide的,驚不驚喜,意不意外!
不是還有mRenderNode.getInverseMatrix嗎?
public void getInverseMatrix(@NonNull Matrix outMatrix) { nGetInverseTransformMatrix(mNativeRenderNode, outMatrix.native_instance); }
RenderNode的getInverseMatrix的方法是public的,是不是很高興?
* * @hide */ public class RenderNode { //................... }
然而RenderNode連class都是hide的,是不是更高興了,連怎麼獲取RenderNode物件都不需要考慮了.
少年並沒有氣餒,不就是個逆矩陣嗎,少年默默在心裡念著"谷歌,要是我搞不定,吃我屎".
既然逆矩陣獲取不到那就獲得原矩陣嘛
/** * The transform matrix of this view, which is calculated based on the current * rotation, scale, and pivot properties. * * @see #getRotation() * @see #getScaleX() * @see #getScaleY() * @see #getPivotX() * @see #getPivotY() * @return The current transform matrix for the view */ public Matrix getMatrix() { ensureTransformationInfo(); final Matrix matrix = mTransformationInfo.mMatrix; mRenderNode.getMatrix(matrix); return matrix; }
很幸運,View的getMatrix是public的,而且沒有hide.
逆的過程也很簡單,Android的Matrix提供了一個invert的方法,最終可以用如下方法代替transformPointToViewLocal
private void transformPointToViewLocal(float[] point, View child) { point[0] += mParent.getScrollX() - child.getLeft(); point[1] += mParent.getScrollY() - child.getTop(); Matrix matrix = child.getMatrix(); if (!matrix.isIdentity()) { Matrix invert = getTempMatrix(); boolean result = matrix.invert(invert); if (result) { invert.mapPoints(point); } } }
然後還有一個問題,關於如何判斷點是否在Path內呢?
這個問題少年只想到了一種比較耗費記憶體的辦法,就是將Path用Canvas繪製成圖片,然後根據點是否符合圖片裡Path內的顏色來判斷.這是一種用記憶體換時間測策略,臥槽,講道理豈止是浪費,簡直是鋪張浪費.少年為了節約記憶體,將圖片大小縮小了16倍,這樣問題應該不大了.少年百度查了下,貌似還有一個Region類可以實現是否在Path內判斷,但是資料其實不多,而且估計每次點都需要計算是否在Path內.少年覺得這種方式沒有轉化成圖片穩,所以目前預設採用了圖片的方式作為判斷.有興趣的可以實現PathRegion替換這部分邏輯.
那麼原理就講到這裡就講完了,具體如何實現的,自己看原始碼去吧.文章底放GitHub連結.
轉場動畫擴充套件
基於ClipPathLayout還可以實現轉場動畫的擴充套件,先放些效果.
兩個View的場景切換效果,Android原生自帶的場景切換效果大部分是由動畫實現的平移,縮小,暗淡.
原生比較少帶有那種PPT播放的切換效果,一些第三方庫實現的效果一般是由在DecorView中新增一層View來實現較為和諧的切換,
滬江開心詞場裡使用的就是這種動畫,這種動畫很棒,但是也有一個小缺點,就是在切換的過程中,切換用的View和即將要切換的View沒有什麼關係.
藉助於ClipPathLayout擴充套件的TransitionFrameLayout也可以實現較為和諧的切換效果,由於是示例,不寫太複雜的場景,以下僅用兩個TextView作為展示

image
在瀏覽QQ空間和使用QQ瀏覽器的過程看到騰訊的廣告切換效果也是很不錯的,這裡藉助於TransitionFrameLayout也可以實現這種效果

image
其實大部分的場景切換應該是用在Fragment中,這裡也用TransitionFragmentContainer實現了Fragment的場景切換效果

image
使用和實現部分放在下篇 基於ClipPathLayout轉場動畫布局的實現 講解.