作者:SoulQw
鏈接:https://blog.csdn.net/u014626094/article/details/105430981
導讀:
文章很快速的接驗證了方案的原理,驗證原理後,其實剩下的就是針對高亮這個需求,如何更好的設計;所以如果跟着作者的思路走下去,可以瞭解到這個庫一步步設計出來的樣子。
在我們的開發過程中,常常遇到這樣的問題,我們的APP開發中要在某個頁面去加一些新功能的引導,最常用的就是將整個頁面做成一個類似於Dialog背景的蒙層,然後將想提示用戶的位置高亮出來,最後加一些元素在上面,那麼大概效果就是這樣:
乍一看很簡單嘛,設計師切個純圖展示不就好了嘛?
其實我們之前的功能都是這麼做的:
需要展示用戶引導頁的時候用一個設計師給的純圖覆蓋在當前頁面.
但是這樣雖然又不是不能用,但其實一直會存在幾個問題:
設計師一套16:9的圖無法適配所有比例的屏幕,其他縱橫比的機型會出現拉伸的情況.
高分辨率手機但一套圖模糊,但是多圖又會增大APk包大小
帶着這個問題,我們去和設計師溝通了一番,後來設計無意間一句話引起了我的思考“既然多圖適配這麼麻煩,你是否可以把那塊控件摳出來呢?”
預期效果:
在不使用純圖的前提下實現一個全屏的蒙層上制定的一個或者多個View的高亮
可行性分析
最初嘗試的方案A:
首先在整個界面畫出一個半透明的全屏蒙層
通過View.getDrawingCache() 獲取該目標View的bitmap緩存
獲取該View在屏幕中的位置,在該位置放置一個ImageView去展示之前拿到的Bitmap緩存,即達到了高亮View的效果
效果: 發現部分View是可以通過該方案實現高亮的.
但是會有幾個的問題:
很多時候,我們看到的View 其實是層疊的,它自己本身沒背景顏色,而背景就繪製在它的Parent中,我們獲取它的DrawingCache 只能拿到一個沒有背景的View緩存圖,而這個結果肯定不是我們那想要的.
如果View通過Shape指定了背景的話,通過這個方式無法獲取背景的圓角或者圓形,只能得到一個矩形的圖
這個獲取View,bitmap的方法在不同機型下有些兼容性問題,部分低端機型下會出現卡頓的情況
最終選擇的方案B:
首先在整個界面畫出一個半透明的全屏蒙層;
找到View在屏幕中的位置,和它當前的大小,直接在蒙層上繪出這個大小的矩形,如果它是有設置背景的,根據它背景的類型,獲取到相關的ShapeDrawable,然後判斷它當前的形狀然後我們繪製跟它背景一模一樣的形狀,然後將這塊區域“鏤空”即可!
那如何鏤空呢?
我們先來看看最終實現效果,後面我們來講實現原理:
而實現上述效果,僅僅需要一行代碼:
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();
}
大致能實現如下功能:
一行代碼完成某個View,或者多個View的高亮展示
同樣支持基於AapterView(如ListView、GridView等) 或RecyclerView 的item以及item中元素的高亮
自動識別圓角背景,也可以自定義任何你想要的形狀
如果依次按順序去高亮一些列View,提供流式操作
原理
接下來我來分解一下主要設計思路,一步步達到我們想要的效果:
在蒙層上“鏤空一塊區域”
回想一下:
我們最開始通過接觸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,下面我們來一步步設計實現它:
因爲我們是要把View鏤空,所以,我們需要寫一個類,包含我們的View,以及它的大小和區域,我們叫他HollowInfo:
public class HollowInfo {
/**
* 目標View 用於定位透明區域
*/
public View targetView;
/**
* 可自定義區域大小
*/
public Rect targetBound;
}
這邊列出了最核心的兩個屬性,第一個是我們核心的的View,我們需要根據它在屏幕上的位置確定我們繪製的起點,第二個是繪製的區域,我們可以使用View自己的的寬高,也可以自定義它的大小.
有了我們的基本繪製實體類,我來定義我們的畫板,它主要做兩件事:
根據指定顏色繪製整個屏幕大小的半透明蒙層
在蒙層上繪製指定大小的鏤空區域
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之外,還需要添加一些文字,或者可交互的元素(比如按鈕)怎麼辦呢?
很簡單嘛! 我們在我們的蒙層View中再蓋上一層去展示額外的元素不就好了!,現在我們只需要給這些元素找一個載體即可~
尋找合適的載體因爲我們是一個引導頁的蒙層,所以我第一時間想到的就是Dialog,
第一方面,dialog構建方便,我們只需要自己構建View填充給它,然後將dialog設爲全屏切透明即可
第二方面,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中再展示出來~
只有兩個細節點我提一下:
DialogFragment 源碼的show中默認使用commit提交Fragment的事務,在一些Activity界面重建的情況下可能出現狀態丟失的異常,我們try/catch住並重新實現保證邏輯的正常執行:
@Override
public void show(FragmentManager manager, String tag) {
try {
super.show(manager, tag);
} catch (Exception e) {
manager.beginTransaction()
.add(this, tag)
.commitAllowingStateLoss();
}
}
全屏透明的Dialog我們使用Theme即可實現:
<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了:
設計調用API最終我們交給用戶使用的時候無非就只剩下這麼幾件事了:
指定要高亮的View,如果有特殊需求形狀等也一併加入
指定顯示在頂層的佈局
像Dialog一樣按需設置回調,設置回退鍵等
代碼細節我精簡了一下,大致就是一個構建者模式:
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中設置點擊事件等等,細節上可以看看沒有精簡過的源碼,這裏就不貼出來了~
CurtainFlow:多個高亮步驟
上面實現了我們一次高亮一個或者多個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管理起來,然後依次取出展示,大家有興趣可以看看源碼~
總結:
一行代碼完成某個View,或者多個View的高亮展示
同樣支持基於AapterView(如ListView、GridView等) 或RecyclerView 的item以及item中元素的高亮
自動識別圓角背景,也可以自定義任何你想要的形狀
如果依次按順序去高亮一些列View,提供流式操作
Github地址
https://github.com/soulqw/Curtain
關注我獲取更多知識或者投稿