Android 視圖圓角化處理方案

前言

最近項目中突然要將用到圖片(項目使用Fresco)及視頻(項目使用TextureView繪制紋理,SurfaceView不在本文討論之列,絕大部分播放器為了視圖可控,現在都會采用TextureView而不是SurfaceView。原因的話那又是另一片大海,自行腦補)的地方都進行圓角化,且需支持可控實驗,即開關開啟時圓角關閉時非圓角。由于工程運行已久,圖片及視頻的地方甚多,除了考慮技術方案外,還需考慮人工成本。

各種圓角化方案探索對比

方案一 : 直接采用Canvas.clipxxx 相關api,裁剪出一個圓角區域

emmmmmm...... 該方案簡單暴力,通用性強。然后,全文終。


黑臉

然后你就會發現,你的頁面也終了。如果只是一個靜態的單圖視圖,該方法問題不大,但如果是復雜頁面,滾動的時候,測試就會跟你說,頁面卡頓了,要優化。
原因就是 Canvas.clip的相關api損耗相對較大。

方案二 :直接Fresco自帶功能

1: 直接使用 roundedCornerRadius屬性,然后你就會驚奇地發現,靜圖很完美,動圖無效了......

2: 經過一番查找,發現fresco已經給出動圖的解決方案,加上roundWithOverlayColor屬性,該屬性支持傳drawable類型,然后動圖可以圓角化了
...淚大普奔,可以下班了。

但是 我們工程原來很多地方是類似頭條這樣,item有點擊背景的.


item點擊背景改變.gif

而圓角只是在圖片的各個角上,使用該屬性實現的話,你會發現如下現象


overlayColor透出4個角不同顏色.gif
  • 問題一:外層背景顏色改變時,蓋住的4個角的顏色,并無隨外層點擊顏色變化,4個角還是上次的默認顏色透出
    這個現象,如果圓角不是很大,且大item的不同狀態間顏色差異不大時,不是很明顯。原因roundWithOverlayColor屬性采用的是一個普通的靜態drawable,當外部背景按下時OverlayDrawable,并無刷新。

    那我們就在外部背景按下時,重新設置roundWithOverlayColor色值,然后重新調用加載圖片?
    原理可行,但通過fresco二次加載的方式,性能還是有點浪費。如果項目中不用處理視頻,或允許視頻與圖片2套的化,圖片圓角化可以采用該方案。

    該方案應該有點可以優化,就是不要調用二次加載方式,而是按下去時
    獲取OverlayDrawable(沒再認真去看源碼,是否叫這名字,暫時這樣叫)然后根據顏色刷新該層drawable就好。Fresco的原理就是一層層的drawable,然后控制器根據當前狀態,來顯示對應層的drawable,猜測roundWithOverlayColor應該是有單獨對應一層drawable的。由于我沒采用該方案,具體細節不再細究。

如果采用該方案,那接下來就又要開啟視頻的圓角方案之旅了

方案三 :最終大招 CardView

經過一番思考后,我們終于想到了 系統提供的CardView,然后我們就開始了 全工程改造。
把原來全工程各個視頻控件和圖片控件的外層,都加上一層CardView,經過多個日夜不停地加班奮戰,幾天過去了,你就會發現,一切運行完美,視頻控件也完美支持圓角化了

  • 問題二:每個視頻控件和圖片控件外層都加上個cardview,做為父layout的話,成本實在太高了。而且個別地方,原來如果是通過childview.getLayoutParams操作原子控件LayoutParams的話,那代碼和布局同時改起來,簡直是...

  • 問題三:套一層的話相當于多一層布局,布局層級更深一層,layout時間加長,性能上面你懂的。在開關關(無需圓角)的情況下,該cardview純屬浪費

  • 問題四:android 5.0 以下的機子你會發現神奇的現象,就是api 21以下的機子,圓角化并不是你想象中的樣子
    直接偷懶網上盜下效果圖,如下

    API21及以上.jpg

    api21以下.jpg

    初步一看,雖然加上了圓角屬性,但是圖片邊上是方的。將左下角和左上角放大仔細看下:
    左下角細節圖.jpg

    左上角.jpg

    可以看到,CardView本身是圓角效果了,但是里邊的內容卻還是方的,并且出現了多余的白邊。
    看來是時候擼一把cardview源碼(基于support 26.0.1其余版本大同小異,這里只分析粘貼最主要代碼)了

public class CardView extends FrameLayout {

  ...
    static {
        if (Build.VERSION.SDK_INT >= 21) {
            IMPL = new CardViewApi21Impl();
        } else if (Build.VERSION.SDK_INT >= 17) {
            IMPL = new CardViewApi17Impl();
        } else {
            IMPL = new CardViewBaseImpl();
        }
        IMPL.initStatic();
    }

   
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (!(IMPL instanceof CardViewApi21Impl)) {
            final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
            switch (widthMode) {
                case MeasureSpec.EXACTLY:
                case MeasureSpec.AT_MOST:
                    final int minWidth = (int) Math.ceil(IMPL.getMinWidth(mCardViewDelegate));
                    widthMeasureSpec = MeasureSpec.makeMeasureSpec(Math.max(minWidth,
                            MeasureSpec.getSize(widthMeasureSpec)), widthMode);
                    break;
            }

            final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            switch (heightMode) {
                case MeasureSpec.EXACTLY:
                case MeasureSpec.AT_MOST:
                    final int minHeight = (int) Math.ceil(IMPL.getMinHeight(mCardViewDelegate));
                    heightMeasureSpec = MeasureSpec.makeMeasureSpec(Math.max(minHeight,
                            MeasureSpec.getSize(heightMeasureSpec)), heightMode);
                    break;
            }
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        } else {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }

    private void initialize(Context context, AttributeSet attrs, int defStyleAttr) {
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CardView, defStyleAttr,
                R.style.CardView);
        ColorStateList backgroundColor;
        if (a.hasValue(R.styleable.CardView_cardBackgroundColor)) {
            backgroundColor = a.getColorStateList(R.styleable.CardView_cardBackgroundColor);
        } else {
            // There isn't one set, so we'll compute one based on the theme
            final TypedArray aa = getContext().obtainStyledAttributes(COLOR_BACKGROUND_ATTR);
            final int themeColorBackground = aa.getColor(0, 0);
            aa.recycle();

          ...

        IMPL.initialize(mCardViewDelegate, context, backgroundColor, radius,
                elevation, maxElevation);
    }

   ...

    };

最主要就是
1:初始化獲取一些xml屬性,變成本地變量方便后續使用
2: onMeasure方法,在api21以下做了特殊處理(一直很想吐槽這個處理方式),具體處理下文分析
3: 根據sdk版本生成不同的實現類 ,cardview只是做為一個空殼,在各種方法被系統調用的時候,調用對應實現類的對應方法。這就是為啥不同api版本,效果不一樣的地方了。

既然問題出現在21以下,我們就先看下CardViewApi17Impl的實現。

其實17~20(CardViewApi17Impl)及17以下(CardViewBaseImpl)的差別很小,僅是在如何繪制圓角上方法(drawRoundRect)不同而已。原因如下,不再詳細分析

 // Draws a round rect using 7 draw operations. This is faster than using
        // canvas.drawRoundRect before JBMR1 because API 11-16 used alpha mask textures to draw
        // shapes.

所以我們直接看CardViewBaseImpl,重點在以下3個方法

    @Override
    public void initialize(CardViewDelegate cardView, Context context,
            ColorStateList backgroundColor, float radius, float elevation, float maxElevation) {
        RoundRectDrawableWithShadow background = createBackground(context, backgroundColor, radius,
                elevation, maxElevation);
        background.setAddPaddingForCorners(cardView.getPreventCornerOverlap());
        cardView.setCardBackground(background);
        updatePadding(cardView);
    }

    private RoundRectDrawableWithShadow createBackground(Context context,
                    ColorStateList backgroundColor, float radius, float elevation,
                    float maxElevation) {
        return new RoundRectDrawableWithShadow(context.getResources(), backgroundColor, radius,
                elevation, maxElevation);
    }

    @Override
    public void updatePadding(CardViewDelegate cardView) {
        Rect shadowPadding = new Rect();
        getShadowBackground(cardView).getMaxShadowAndCornerPadding(shadowPadding);
        cardView.setMinWidthHeightInternal((int) Math.ceil(getMinWidth(cardView)),
                (int) Math.ceil(getMinHeight(cardView)));
        cardView.setShadowPadding(shadowPadding.left, shadowPadding.top,
                shadowPadding.right, shadowPadding.bottom);
    }

其實就是又把大部分工作交給了RoundRectDrawableWithShadow處理,然后將創建該drawable對象設為背景。然后本類中只處理一些外層間距問題,主要是外層陰影在21以下的實現方式(又是一個坑),最后我們跟蹤RoundRectDrawableWithShadow,重點在

   @Override
    public void draw(Canvas canvas) {
        if (mDirty) {
            buildComponents(getBounds());
            mDirty = false;
        }
        canvas.translate(0, mRawShadowSize / 2);
        drawShadow(canvas);
        canvas.translate(0, -mRawShadowSize / 2);
        sRoundRectHelper.drawRoundRect(canvas, mCardBounds, mCornerRadius, mPaint);
    }

通過buildComponents及drawShadow方法,就會發現,其實在舊版本上,是采用設padding,講cardview包裹的原布局縮小2層(1層用于顯示陰影、1層用于繪制上圖看到的原視頻外的白色圓角部分),個人感覺這種方式灰常坑,首先強制將原來控件尺寸(ui要找麻煩了)改了不說,空出來繪制得出的陰影也是很呵呵,與21以上的效果完全不是一個級別;圓角效果也是相當于外層多了個圓角,而不是原視圖上做的改動。這就明白了,為啥會出現上圖的樣子了。

那為啥5.0以上的效果會沒有問題呢? 接下來就是重點CardViewApi21Impl,其實大概流程與CardViewApi17Impl 干的事差不多,區別僅在于drawable不一樣,他是使用RoundRectDrawable做背景。繼續跟蹤RoundRectDrawable發現其實做的事與RoundRectDrawableWithShadow差不多。重點在于

  /**
     * Ensures the tint filter is consistent with the current tint color and
     * mode.
     */
    private PorterDuffColorFilter createTintFilter(ColorStateList tint, PorterDuff.Mode tintMode) {
        if (tint == null || tintMode == null) {
            return null;
        }
        final int color = tint.getColorForState(getState(), Color.TRANSPARENT);
        return new PorterDuffColorFilter(color, tintMode);
    }

該PorterDuff.Mode為PorterDuff.Mode.SRC_IN(這方面知識,自行腦補,賦上一張簡圖)


PorterDuff.jpg

該drawble 默認在4個角用透明像素繪制了4個圓角,然后結合PorterDuff.Mode.SRC_IN模式。
最后最關鍵的是要配合上view的setClipToOutline方法,就可以實現視圖圓角了,但是這些api都是21及以后才有的,所以你懂的。

雖然cardview的方式,不適合我們。但是,api 21以后的這種方式給我們提供了一種思路,只需要設個RoundRectDrawable(support包中該類不對外開發,我們可以自行復制實現)當背景,然后打開view的setClipToOutline方法。2行代碼即可搞定,瞬間解決了以上所有遇到問題

方案四 :痛苦的兼容及及全通用方案處理

如果項目可以不用兼容5.0以下機子,該部分可以不看了

原理:自己造輪子在view的最上層繪制一層與背景一樣顏色的圓角,擋住下面的視圖
原理很簡單,但實現起來有有幾個點要注意:
1:必須能先知道外層布局各種狀態的底色,且外層背景顏色如果并非單色,那就涼涼了

  • 像這種外層有相應點擊事件的情況,外層view還需要通知里層view 刷新對應圓角顏色;而且里層view無論有沒單獨的點擊事件。蓋上去的這層都不能根據本身view的狀態變色,否則也會出現問題一的情況
  • 工程中有換膚功能,還必須兼容各種換膚情況

2:技術選型上,繪制覆蓋層的圓角是否直接在view上繪制。直接在view上繪制可行,但通用性相對較低,相當于各種需要圓角的view都要去自定義一個原來的子類,在原子類上繪制一層。并且外層view狀態的傳遞給里層view的方式代碼寫起來也會相對摳腳。建議采用drawable的方式,在各個view上層繪制一次該drawable即可。然后內外兩層view用戶操作狀態的傳遞及如何刷新,實現方案不一,代碼及工程量也不一

在這里就不再詳細對比各種細節,直接上個人思考良久,綜合各方面考量,最終覺得比較合理的方式,轉換成demo,有興趣的同學,自行點擊鏈接查看

ConnerDemo

總結

總的來說各種實現圓角的方案大概原理可概括為以下幾種
1:直接裁剪視圖型,簡單暴力
2:利用各種圖形重疊區域的api及模式,產生效果,但可能會有api版本問題
3:直接在原視圖上蓋層底色圓角。方案通用,但實現方式不一樣,代碼量及通用性可以相差不少

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。