在咱們的開發過程當中,經常遇到這樣的問題,咱們的APP開發中要在某個頁面去加一些新功能的引導,最經常使用的就是將整個頁面作成一個相似於Dialog背景的蒙層,而後將想提示用戶的位置高亮出來,最後加一些元素在上面,那麼大概效果就是這樣:java
乍一看很簡單嘛,設計師切個純圖展現不就行了嘛? 其實咱們以前的功能都是這麼作的: 須要展現用戶引導頁的時候用一個設計師給的純圖覆蓋在當前頁面.android
可是這樣雖然又不是不能用,但其實一直會存在幾個問題:git
帶着這個問題,咱們去和設計師溝通了一番,後來設計無心間一句話引發了個人思考「既然多圖適配這麼麻煩,你是否能夠把那塊控件摳出來呢?」github
在不使用純圖的前提下實現一個全屏的蒙層上制定的一個或者多個View的高亮canvas
效果: 發現部分View是能夠經過該方案實現高亮的,可是會有幾個的問題:數組
那如何鏤空呢? 咱們先來看看最終實現效果,後面咱們來說實現原理:緩存
而實現上述效果,僅僅須要一行代碼:bash
private void showInitGuide() { new Curtain(SimpleGuideActivity.this) .with(findViewById(R.id.iv_guide_first)) .with(findViewById(R.id.btn_shape_circle)) .with(findViewById(R.id.btn_shape_custom)) .show(); } 複製代碼
大體能實現以下功能:markdown
接下來我來分解一下主要設計思路,一步步達到咱們想要的效果:app
回想一下: 咱們最開始經過接觸CircleImageView,瞭解到View繪製過程當中,圖層層疊有16種疊加效果:
那麼咱們繪製的圖層1不就是半透明的背景,而圖層2就是咱們的View的形狀區域,咱們只要找到一個疊加公共區域透明的效果是否是就是實現了鏤空的效果了?因此這邊我選擇了DstOut效果,因此核心代碼以下:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawBackGround(canvas); drawHollowFields(canvas); } /** * 畫一個半透明的背景 */ private void drawBackGround(Canvas canvas) { mPaint.setXfermode(null); mPaint.setColor(mCurtainColor); canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint); } /** * 畫出透明區域 */ private void drawHollowFields(Canvas canvas) { mPaint.setColor(Color.WHITE); mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); //測試畫一個圓 canvas.drawCircle(getWidth()/2,getHeight()/4,300, mPaint); } 複製代碼
效果以下,是否是已經鏤空了?
固然,這裏就是核心的邏輯了,實際上咱們須要高亮的是咱們的View,下面咱們來一步步設計實現它:
public class HollowInfo { /** * 目標View 用於定位透明區域 */ public View targetView; /** * 可自定義區域大小 */ public Rect targetBound; } 複製代碼
這邊列出了最核心的兩個屬性,第一個是咱們核心的的View,咱們須要根據它在屏幕上的位置肯定咱們繪製的起點,第二個是繪製的區域,咱們可使用View本身的的寬高,也能夠自定義它的大小.
2.有了咱們的基本繪製實體類,我來定義咱們的畫板,它主要作兩件事:
public class GuideView extends View { private HollowInfo[] mHollows; private int mCurtainColor = 0x88000000; private Paint mPaint; public GuideView(@NonNull Context context) { super(context, null); init(); } private void init() { mPaint = new Paint(ANTI_ALIAS_FLAG); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //固然是全屏大小 setMeasuredDimension(getScreenWidth(getContext()), getScreenHeight(getContext())); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int count; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { count = canvas.saveLayer(0, 0, getWidth(), getHeight(), null); } else { count = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG); } drawBackGround(canvas); drawHollowFields(canvas); canvas.restoreToCount(count); } private void drawBackGround(Canvas canvas) { mPaint.setXfermode(null); mPaint.setColor(mCurtainColor); canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint); } /** * 繪製全部鏤空區域 */ private void drawHollowFields(Canvas canvas) { mPaint.setColor(Color.WHITE); mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); //可能有多個View 須要高亮, 因此遍歷數組 for (HollowInfo mHollow : mHollows) { drawSingleHollow(mHollow, canvas); } } private void drawSingleHollow(HollowInfo info, Canvas canvas) { if (mHollows.length <= 0) { return; } info.targetBound = new Rect(); //獲取View的邊界方框 info.targetView.getDrawingRect(info.targetBound); int[] viewLocation = new int[2]; info.targetView.getLocationOnScreen(viewLocation); info.targetBound.left = viewLocation[0]; info.targetBound.top = viewLocation[1]; info.targetBound.right += info.targetBound.left; info.targetBound.bottom += info.targetBound.top; //要減去狀態欄的高度 info.targetBound.top -= getStatusBarHeight(getContext()); info.targetBound.bottom -= getStatusBarHeight(getContext()); //繪製鏤空區域 realDrawHollows(info, canvas); } private void realDrawHollows(HollowInfo info, Canvas canvas) { canvas.drawRect(info.targetBound, mPaint); } } 複製代碼
效果以下:
到目前咱們已經把圖片ImageView高亮了,彷佛已經完成了,可是咱們細看一下,它下面有兩個設置了Shape的按鈕,分別是圓形和圓角的,而咱們代碼中只繪製了矩形,因此確定是沒辦法適配圓角的,那怎麼辦呢?
對!,咱們能夠從View的backGround入手,由於咱們能設置各類shape的Drawable實際上就是GradientDrawable,咱們能夠同過判斷它的類型,而後經過反射獲取咱們想要的屬性,咱們修改realDrawHollows代碼以下:
/** * 繪製鏤空區域 */ private void realDrawHollows(HollowInfo info, Canvas canvas) { if (!drawHollowSpaceIfMatched(info, canvas)) { //沒有匹配上,默認降級方案:畫一個矩形 canvas.drawRect(info.targetBound, mPaint); } } private boolean drawHollowSpaceIfMatched(HollowInfo info, Canvas canvas) { //android shape backGround Drawable drawable = info.targetView.getBackground(); if (drawable instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable); return true; } return false; } private void drawGradientHollow(HollowInfo info, Canvas canvas, Drawable drawable) { Field fieldGradientState; Object mGradientState = null; int shape = GradientDrawable.RECTANGLE; try { fieldGradientState = Class.forName("android.graphics.drawable.GradientDrawable").getDeclaredField("mGradientState"); fieldGradientState.setAccessible(true); mGradientState = fieldGradientState.get(drawable); Field fieldShape = mGradientState.getClass().getDeclaredField("mShape"); fieldShape.setAccessible(true); shape = (int) fieldShape.get(mGradientState); } catch (Exception e) { e.printStackTrace(); } float mRadius = 0; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { mRadius = ((GradientDrawable) drawable).getCornerRadius(); } else { try { Field fieldRadius = mGradientState.getClass().getDeclaredField("mRadius"); fieldRadius.setAccessible(true); mRadius = (float) fieldRadius.get(mGradientState); } catch (Exception e) { e.printStackTrace(); } } if (shape == GradientDrawable.OVAL) { canvas.drawOval(new RectF(info.targetBound.left, info.targetBound.top, info.targetBound.right, info.targetBound.bottom), mPaint); } else { float rad = Math.min(mRadius, Math.min(info.targetBound.width(), info.targetBound.height()) * 0.5f); canvas.drawRoundRect(new RectF(info.targetBound.left, info.targetBound.top, info.targetBound.right, info.targetBound.bottom), rad, rad, mPaint); } } 複製代碼
在獲取到背景類型時候,我若是肯定了是咱們想要的GradientDrawable以後,咱們就去獲取它的形狀實際類型,是橢圓仍是圓角,再獲取它的圓角度數,能拿到直接拿,拿不到經過反射的方式,最後繪製出相應的形狀便可.
固然,咱們View的背景多是一個Selector,因此咱們須要外加一層判斷:取它當前的第一個:
private boolean drawHollowSpaceIfMatched(HollowInfo info, Canvas canvas) { //android shape backGround Drawable drawable = info.targetView.getBackground(); if (drawable instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable); return true; } //android selector backGround if (drawable instanceof StateListDrawable) { if (drawable.getCurrent() instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable.getCurrent()); return true; } } return false; } 複製代碼
咱們再來看看這麼作以後的效果:
雖然咱們能本身適配View的背景,可能不能包含全部Drawable的,好比RippleDrawable,並且實際業務場景確定很複雜,也許產品須要特別的高亮形狀?一個好的代碼確定要有拓展的能力,咱們可否將圖形的方法自定義?,接下來咱們自定義一個Shape:
public interface Shape { /** * 畫你想要的任何形狀 */ void drawShape(Canvas canvas, Paint paint, HollowInfo info); } 複製代碼
在HolloInfo中增長Shape,由用戶在構建HolloInfo時候傳入:
public class HollowInfo { /** * 目標View 用於定位透明區域 */ public View targetView; /** * 可自定義區域大小 */ public Rect targetBound; /** * 指定的形狀 */ public Shape shape; } 複製代碼
再來補充咱們的drawHollowSpaceIfMatched方法:若是用戶指定了形狀的話,咱們優先畫形狀,不然再自動適配它的背景:
private boolean drawHollowSpaceIfMatched(HollowInfo info, Canvas canvas) { //user custom shape if (null != info.shape) { info.shape.drawShape(canvas, mPaint, info); return true; } //android shape backGround Drawable drawable = info.targetView.getBackground(); if (drawable instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable); return true; } //android selector backGround if (drawable instanceof StateListDrawable) { if (drawable.getCurrent() instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable.getCurrent()); return true; } } return false; } 複製代碼
我如今自定義一個圓角的形狀:
public class RoundShape implements Shape { private float radius; public RoundShape(float radius) { this.radius = radius; } @Override public void drawShape(Canvas canvas, Paint paint, HollowInfo info) { canvas.drawRoundRect(new RectF(info.targetBound.left, info.targetBound.top, info.targetBound.right, info.targetBound.bottom), radius, radius, paint); } } private void showInitGuide() { new Curtain(SimpleGuideActivity.this) //自定義高亮形狀 .withShape(findViewById(R.id.btn_shape_circle), new RoundShape(12)).show(); } 複製代碼
咱們設置給一個圓形的View 那麼效果以下:
因此,只要自定義了Shape,形狀交給你,想怎麼自定義都行~
到這裏有朋友問了...那我除了高亮View以外,還須要添加一些文字,或者可交互的元素(好比按鈕)怎麼辦呢?
由於咱們是一個引導頁的蒙層,因此我第一時間想到的就是Dialog,
固然構建Dialog,咱們固然推薦DialogFragment,方便管理橫豎屏的狀況,也是谷歌推薦的作法, 那麼核心代碼以下:
public class GuideDialogFragment extends DialogFragment { private static final int MAX_CHILD_COUNT = 2; private static final int GUIDE_ID = 0x3; private FrameLayout contentView; private Dialog dialog; private int topLayoutRes = 0; private GuideView guideView; public void show() { FragmentActivity activity = (FragmentActivity) guideView.getContext(); guideView.setId(GUIDE_ID); this.contentView = new FrameLayout(activity); this.contentView.addView(guideView); if (topLayoutRes != 0) { updateTopView(); } //定義一個全透明主題的Dialog dialog = new AlertDialog.Builder(activity, R.style.TransparentDialog) .setView(contentView) .create(); show(activity.getSupportFragmentManager(), GuideDialogFragment.class.getSimpleName()); } void updateContent() { contentView.removeAllViews(); contentView.addView(guideView); if (contentView.getChildCount() == MAX_CHILD_COUNT) { contentView.removeViewAt(1); } //將自定義的View 佈局加載入contentView的頂層達到層疊的效果 LayoutInflater.from(contentView.getContext()).inflate(topLayoutRes, contentView, true); } /** * 防止出現狀態丟失 */ @Override public void show(FragmentManager manager, String tag) { try { super.show(manager, tag); } catch (Exception e) { manager.beginTransaction() .add(this, tag) .commitAllowingStateLoss(); } } @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { return dialog; } @Override public void onDestroyView() { super.onDestroyView(); if (dialog != null) { dialog = null; } } private void updateTopView() { if (contentView.getChildCount() == MAX_CHILD_COUNT) { contentView.removeViewAt(1); } LayoutInflater.from(contentView.getContext()).inflate(topLayoutRes, contentView, true); } } 複製代碼
代碼很簡單,核心就是建立一個Dialog,將咱們的透明的View和頂層包含其餘元素的TopView放入Dialog的contentView中再展現出來~
只有兩個細節點我提一下:
@Override public void show(FragmentManager manager, String tag) { try { super.show(manager, tag); } catch (Exception e) { manager.beginTransaction() .add(this, tag) .commitAllowingStateLoss(); } } 複製代碼
<style name="TransparentDialog" parent="@android:style/Theme.Dialog"> <item name="android:windowIsFloating">false</item> <item name="android:windowNoTitle">true</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowAnimationStyle">@null</item> <item name="android:windowBackground">@android:color/transparent</item> </style> 複製代碼
載體咱們就作好了,接下來就是設計調用API了:
最終咱們交給用戶使用的時候無非就只剩下這麼幾件事了:
代碼細節我精簡了一下,大體就是一個構建者模式:
public class Curtain { SparseArray<HollowInfo> hollows; boolean cancelBackPressed = true; int topViewId; FragmentActivity activity; public Curtain(Fragment fragment) { this(fragment.getActivity()); } public Curtain(FragmentActivity activity) { this.activity = activity; this.hollows = new SparseArray<>(); } /** * @param which 頁面上任一要高亮的view */ public Curtain with(@NonNull View which) { getHollowInfo(which); return this; } /** * 設置自定義形狀 * * @param which 目標view * @param shape 形狀 */ public Curtain withShape(@NonNull View which, Shape shape) { getHollowInfo(which).setShape(shape); return this; } /** * 自定義的引導頁蒙層上層的元素 */ public Curtain setTopView(@LayoutRes int layoutId) { this.topViewId = layoutId; return this; } public void show() { //載體dialog GuideDialogFragment guider = new GuideDialogFragment(); guider.setTopViewRes(topViewId); //半透明蒙層View GuideView guideView = new GuideView(activity); //將透明區域設置蒙層VIew addHollows(guideView); guider.setGuideView(guideView); guider.show(); } void addHollows(GuideView guideView) { HollowInfo[] tobeDraw = new HollowInfo[hollows.size()]; for (int i = 0; i < hollows.size(); i++) { tobeDraw[i] = hollows.valueAt(i); } guideView.setHollowInfo(tobeDraw); } private HollowInfo getHollowInfo(View which) { HollowInfo info = hollows.get(which.hashCode()); if (null == info) { info = new HollowInfo(which); info.targetView = which; hollows.append(which.hashCode(), info); } return info; } } 複製代碼
咱們能夠看到經過構建者模式將一個個View封裝爲咱們最開始定義的HollowInfo,放入SparseArray,而後經過Show方法建立咱們的蒙層View,再構建咱們的載體,將他們合併起來.
咱們來個最終版調用: 先寫一個頂部修飾TopView佈局: view_guide_1.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:background="#66000000" tools:ignore="HardcodedText"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="140dp" android:layout_marginTop="300dp" android:text="自動識別View背景形狀,也能夠本身指定和定義高亮形狀" android:textColor="#FFFFFF" android:textSize="18sp" android:textStyle="bold" /> </FrameLayout> 複製代碼
而後結合起來使用:
private void showInitGuide() { new Curtain(SimpleGuideActivity.this) .with(findViewById(R.id.iv_guide_first)) .setTopView(R.layout.view_guide_1) .show(); } 複製代碼
效果以下:
固然也能支持展現回調,在TopView中設置點擊事件等等,細節上能夠看看沒有精簡過的源碼,這裏就不貼出來了~
curtain實現了咱們一次高亮一個或者多個View的狀況,可是實際業務場景每每很複雜,須要第一次高亮ViewA ,結束以後高亮ViewB,和ViewC,而後每次描述的文字或者元素都不同,以下:
咱們將每一步的Curtain對象放入一個流對象來管理,能夠靈活進退,自由慣例,能夠有效減小方法嵌套:
定義接口:
public interface CurtainFlowInterface { /** * 到下個 * 若是下個沒有,即等於 finish() */ void push(); /** * 回到上個 */ void pop(); /** * 按照id 去某個節點 * * @param curtainId */ void toCurtainById(int curtainId); /** * 找到當前展現curtain 中到view元素 */ <T extends View> T findViewInCurrentCurtain(@IdRes int id); /** * 結束 */ void finish(); } 複製代碼
定了接口咱們大體知道能提供什麼功能了,實現的話,咱們只須要吧Curtain對象放入其中進行管理便可,咱們看下使用流程:
/** * 第一步 高亮一個View */ private static final int ID_STEP_1 = 1; /** * 第二步 高亮一個帶圓形的View */ private static final int ID_STEP_2 = 2; /** * 第三步 爲一個View指定自定義的透明形狀 */ private static final int ID_STEP_3 = 3; private Curtain getStepOneGuide() { return new Curtain(CurtainFlowGuideActivity.this) .with(findViewById(R.id.iv_guide_first)) .setTopView(R.layout.view_guide_flow1); } private Curtain getStepTwoGuide() { return new Curtain(CurtainFlowGuideActivity.this) .with(findViewById(R.id.btn_shape_circle)) .setTopView(R.layout.view_guide_flow2); } private Curtain getStepThreeGuide() { return new Curtain(CurtainFlowGuideActivity.this) //自定義高亮形狀 .withShape(findViewById(R.id.btn_shape_custom), new RoundShape(12)) //自定義高亮形狀的Padding .withPadding(findViewById(R.id.btn_shape_custom), 24) .setTopView(R.layout.view_guide_flow3); } 複製代碼
配合咱們的FLow:
private void showInitGuide() { new CurtainFlow.Builder() .with(ID_STEP_1, getStepOneGuide()) .with(ID_STEP_2, getStepTwoGuide()) .with(ID_STEP_3, getStepThreeGuide()) .create() .start(new CurtainFlow.CallBack() { @Override public void onProcess(int currentId, final CurtainFlowInterface curtainFlow) { switch (currentId) { case ID_STEP_2: //回到上個 curtainFlow.findViewInCurrentCurtain(R.id.tv_to_last) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.pop(); } }); break; case ID_STEP_3: curtainFlow.findViewInCurrentCurtain(R.id.tv_to_last) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.pop(); } }); //從新來一遍,即回到第一步 curtainFlow.findViewInCurrentCurtain(R.id.tv_retry) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.toCurtainById(ID_STEP_1); } }); break; } //去下一個 curtainFlow.findViewInCurrentCurtain(R.id.tv_to_next) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.push(); } }); } @Override public void onFinish() { Toast.makeText(CurtainFlowGuideActivity.this, "all flow ended", Toast.LENGTH_SHORT).show(); } }); } 複製代碼
CurtainFlow的實現源碼我就不貼出來具體分析了,大體就是吧Curtain對象按照經過咱們在靜態常量中定義的ID和和Curtain對象經過SparseArray管理起來,而後依次取出展現,你們有興趣能夠看看源碼~