SnackBar 的使用及工作原理

一. 使用

Snackbar 是Android Support Design Library库中的一个控件,可以在屏幕底部快速弹出消息,Snackbar 显示在所有屏幕其它元素之上(屏幕最顶层),同一时间只能显示一个Snackbar,和Toast 类似,但显示要比Toast 丰富,还提供了于用户交互的接口,使用时要导入com.android.support:design 库

dependencies {
     compile 'com.android.support:design:26.1.0'
}

1. 基本使用

基本使用非常简单,一行代码即可

Snackbar.make(view, "hello world", Snackbar.LENGTH_SHORT).show();

上面代码中,view 参数做为Snackbar 的容器,第二个参数为显示的信息,第三个参数是显示时长,有三种类型:
LENGTH_SHORTLENGTH_LONGLENGTH_INDEFINITE
其中设置为LENGTH_INDEFINITE 的话SnackBar 将一直显示直到有另一个SnackBar 或其他原因将其消失,可以通过 setDuration(int milliseconds) 方法设置显示时长,单位毫秒,通过这个方法设置的时长会覆盖make 方法里传的时长参数。

2. 增加交互事件

setAction 方法可以设置Snackbar右侧按钮,增加进行交互事件。如果不使用setAction()则只显示左侧message。

Snackbar.make(findViewById(R.id.container), "hello world", Snackbar.LENGTH_SHORT)
        .setAction("click me", v -> button.setText("change"))
        .show();

setAction 需要两个参数,第一个是按钮显示的文字,第二个是一个事件listener,用来响应点击事件。

3. 显示、消失时的事件

如果想在Snackbar 的显示时或消失时做些什么,可以调用Snackbar的addCallback()方法 (setCallback方法API 25.1.0过时了)。

Snackbar.make(findViewById(R.id.container), "hello world", Snackbar.LENGTH_INDEFINITE)
        .setDuration(5000)
        .setAction("click me", v -> button.setText("change"))
        .addCallback(new Snackbar.Callback(){
            @Override
            public void onShown(Snackbar sb) {      //显示时的回调
                Toast.makeText(TestActivity.this, "Snackbar show", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onDismissed(Snackbar transientBottomBar, int event) {   //消失时的回调
                Toast.makeText(TestActivity.this, "Snackbar dismiss", Toast.LENGTH_SHORT).show();
            }
        })
        .show();

4. 改变Snackbar 的显示样式

Snackbar的官方API只提供了setActionTextColor() 这个方法修改Action 的文字颜色,所以要想改变Snackbar 的样式就要另辟蹊径。通过查看源码发现Snackbar 的布局是SnackbarContentLayout 类,它继承自LinearLayout,同时加载了R.layout.design_layout_snackbar_include 对应的XML 文件,源码如下



    

    

这样我们知道了Snackbar 左侧TextView 的id,也知道了右侧button 的id,还知道了整体布局的类型,还能通过getView 方法获取到布局的View,所以我们就可以对Snackbar 的样式为所欲为、为所欲为...了。

简单示例:

1)改变背景及组件颜色

View view = snackbar.getView(); //获取Snackbar的view
if(view!=null){
    view.setBackgroundColor(backgroundColor); //修改view的背景色  
    ((TextView) view.findViewById(R.id.snackbar_text)).setTextColor(messageColor); //获取Snackbar的message控件,修改字体颜色
}

2)增加图标

先来看谷歌 Material Design设计规范中的一段话:

短文本
通常 Snackbar 的高度应该仅仅用于容纳所有的文本,而文本应该与执行的操作相关。Snackbar 中不能包含图标,操作只能以文本的形式存在。

最多0-1个操作,不包含取消按钮
当一个动作发生的时候,应当符合提示框和可用性规则。当有2个或者2个以上的操作出现时,应该使用提示框而不是 Snackbar,即使其中的一个是取消操作。如果 Snackbar 中提示的操作重要到需要打断屏幕上正在进行的操作,那么理当使用提示框而非 Snackbar。

也就是说,不要对Snackbar 乱搞,加图片什么的是不符合规范滴,然而一旦有这种奇葩需求,或者自己想瞎搞着玩怎么办?

上面提到SnackbarContentLayout 是继承自LinearLayout 的,所以在getView 之后可以给它加个View 进去,代码如下

public static void snackbarAddView(Snackbar snackbar,int layoutId,int index) {
    View snackbarview = snackbar.getView();         //获取snackbar的View(其实就是SnackbarContentLayout)
    SnackbarContentLayout snackbarLayout=(SnackbarContentLayout)snackbarview;//将获取的View转换成SnackbarLayout

    View add_view = LayoutInflater.from(snackbarview.getContext()).inflate(layoutId,null);//加载布局文件新建View
    LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT,LinearLayout.LayoutParams.WRAP_CONTENT);//设置新建布局参数
    p.gravity= Gravity.CENTER_VERTICAL; //设置新建布局在Snackbar内垂直居中显示
    snackbarLayout.addView(add_view,index,p);   //将新建布局添加进snackbarLayout相应位置
}

使用上述方法的时候要注意新加布局的大小和Snackbar内文字长度,Snackbar过大或过于花哨了也不好看。

在实际应用中,可以将Snackbar 的样式修改方法进行封装,写成工具方法。

二. 工作原理

1. 创建

Snackbar 通过make 方法创建实例,make 方法的实现也比较简单,就是创建了Snackbar 实例并初始化布局,然后设置要显示的text 和持续时间

public static Snackbar make(@NonNull View view, @NonNull CharSequence text,
        @Duration int duration) {
    final ViewGroup parent = findSuitableParent(view);
    if (parent == null) {
        throw new IllegalArgumentException("No suitable parent found from the given view. "
                + "Please provide a valid view.");
    }

    final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
    final SnackbarContentLayout content =
            (SnackbarContentLayout) inflater.inflate(
                    R.layout.design_layout_snackbar_include, parent, false);
    final Snackbar snackbar = new Snackbar(parent, content, content);
    snackbar.setText(text);
    snackbar.setDuration(duration);
    return snackbar;
}

在代码中看到调用过findSuitableParent 方法,这个方法的作用就是为Snackbar 的View 找到一个合适的父布局,通过他的代码实现,这个父布局最终是一个CoordinatorLayout,或者是decor Content View 也就是Activity 中setContentView 设置的View,或者是一个最外层的FrameLayout。

Snackbar 的构造方法传入了三个参数,分别是容器布局parent,Snackbar 的内容布局content,一个ContentViewCallback 回调,Snackbar 的构造方法调用了父类BaseTransientBottomBar 的构造方法,来看一下这个父类方法的实现,

protected BaseTransientBottomBar(@NonNull ViewGroup parent, @NonNull View content,
        @NonNull ContentViewCallback contentViewCallback) {
    ...

    mTargetParent = parent;
    mContentViewCallback = contentViewCallback;
    mContext = parent.getContext();

    ThemeUtils.checkAppCompatTheme(mContext);

    LayoutInflater inflater = LayoutInflater.from(mContext);
    // Note that for backwards compatibility reasons we inflate a layout that is defined
    // in the extending Snackbar class. This is to prevent breakage of apps that have custom
    // coordinator layout behaviors that depend on that layout.
    mView = (SnackbarBaseLayout) inflater.inflate(
            R.layout.design_layout_snackbar, mTargetParent, false);
    mView.addView(content);

    ViewCompat.setAccessibilityLiveRegion(mView,
            ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE);
    ViewCompat.setImportantForAccessibility(mView,
            ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);

    // Make sure that we fit system windows and have a listener to apply any insets
    ViewCompat.setFitsSystemWindows(mView, true);
    ViewCompat.setOnApplyWindowInsetsListener(mView,
            new android.support.v4.view.OnApplyWindowInsetsListener() {
                @Override
                public WindowInsetsCompat onApplyWindowInsets(View v,
                        WindowInsetsCompat insets) {
                    // Copy over the bottom inset as padding so that we're displayed
                    // above the navigation bar
                    v.setPadding(v.getPaddingLeft(), v.getPaddingTop(),
                            v.getPaddingRight(), insets.getSystemWindowInsetBottom());
                    return insets;
                }
            });

    mAccessibilityManager = (AccessibilityManager)
            mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
}

主要有如下操作:

1)为各属性赋值

2)创建一个SnackbarBaseLayout 类型的mView,SnackbarBaseLayout 继承自FrameLayout,用来包含传进来的content view

3)将content view add进mView

4)使用ViewCompat 设置mView 的相关功能,之所以使用ViewCompat 是为了保证兼容性

到这里Snackbar 就算创建完了,那么它是如何显示的呢?

2. 显示

首先来看一下Snackbar 的show 方法,Snackbar 中没有定义show 方法,所以是直接调用父类的show 方法:

public void show() {
    SnackbarManager.getInstance().show(mDuration, mManagerCallback);
}

这里show 方法调用了SnackbarManager 的show 方法,并传递了一个时长参数,和一个manager 回调。

SnackbarManager 是一个单例,负责管理Snackbar,其中show 方法实现如下

public void show(int duration, Callback callback) {
    synchronized (mLock) {
        if (isCurrentSnackbarLocked(callback)) {
            // Means that the callback is already in the queue. We'll just update the duration
            mCurrentSnackbar.duration = duration;

            // If this is the Snackbar currently being shown, call re-schedule it's
            // timeout
            mHandler.removeCallbacksAndMessages(mCurrentSnackbar);
            scheduleTimeoutLocked(mCurrentSnackbar);
            return;
        } else if (isNextSnackbarLocked(callback)) {
            // We'll just update the duration
            mNextSnackbar.duration = duration;
        } else {
            // Else, we need to create a new record and queue it
            mNextSnackbar = new SnackbarRecord(duration, callback);
        }

        if (mCurrentSnackbar != null && cancelSnackbarLocked(mCurrentSnackbar,
                Snackbar.Callback.DISMISS_EVENT_CONSECUTIVE)) {
            // If we currently have a Snackbar, try and cancel it and wait in line
            return;
        } else {
            // Clear out the current snackbar
            mCurrentSnackbar = null;
            // Otherwise, just show it now
            showNextSnackbarLocked();
        }
    }
}

这个方法主要做了如下工作:

1)根据callback 判断要显示的Snackbar 是不是当前的Snackbar,如果是就更新显示时间,然后执行定时取消的方法,退出show 方法

2)如果是下一个要显示的Snackbar,更新下一个要显示的Snackbar 的duration

3)如果不是以上两种情况,就创建一个新的SnackbarRecord 作为下一个要显示的Snackbar

4)如果当前的Snackbar 不为空,调用cancelSnackbarLocked 来隐藏Snackbar,然后退出show方法,如果当前的Snackbar 为空,则显示下一个Snackbar

上面提到了SnackbarRecord,这个类负责记录Snackbar,封装了一个Callback(是一个接口,定义了show、和dismiss 两个方法)的弱引用,一个显示时长duration,SnackbarManager 的mCurrentSnackbar和mNextSnackbar 都是SnackbarRecord 类型,显示和隐藏都通过内部的callback 完成。而BaseTransientBottomBar 在调用SnackbarManager 的show 方法时传入了一个callback,这个callback 是BaseTransientBottomBar 内的 mManagerCallback,通过匿名内部类的方式实现了Callback 接口,负责show和dismiss。

所以显示操作就通过showNextSnackbarLocked 方法完成,这个方法也比较简单,主要做了两个工作:

1)设置当前SnackbarRecord,即mCurrentSnackbar = mNextSnackbar

2)从当前SnackbarRecord 中取出callback,执行callback 的show 方法

刚才说到,callback 是BaseTransientBottomBar 传入的mManagerCallback,所以又回到了BaseTransientBottomBar 中,mManagerCallback 实现了show 方法,主要是向Handler 发送了一个显示的消息,Handler 接收到显示消息后,调用了showView 方法:

final void showView() {
    if (mView.getParent() == null) {
        final ViewGroup.LayoutParams lp = mView.getLayoutParams();

        if (lp instanceof CoordinatorLayout.LayoutParams) {
            // If our LayoutParams are from a CoordinatorLayout, we'll setup our Behavior
            final CoordinatorLayout.LayoutParams clp = (CoordinatorLayout.LayoutParams) lp;

            final Behavior behavior = new Behavior();
            behavior.setStartAlphaSwipeDistance(0.1f);
            behavior.setEndAlphaSwipeDistance(0.6f);
            behavior.setSwipeDirection(SwipeDismissBehavior.SWIPE_DIRECTION_START_TO_END);
            behavior.setListener(new SwipeDismissBehavior.OnDismissListener() {
                @Override
                public void onDismiss(View view) {
                    view.setVisibility(View.GONE);
                    dispatchDismiss(BaseCallback.DISMISS_EVENT_SWIPE);
                }

                @Override
                public void onDragStateChanged(int state) {
                    switch (state) {
                        case SwipeDismissBehavior.STATE_DRAGGING:
                        case SwipeDismissBehavior.STATE_SETTLING:
                            // If the view is being dragged or settling, pause the timeout
                            SnackbarManager.getInstance().pauseTimeout(mManagerCallback);
                            break;
                        case SwipeDismissBehavior.STATE_IDLE:
                            // If the view has been released and is idle, restore the timeout
                            SnackbarManager.getInstance()
                                    .restoreTimeoutIfPaused(mManagerCallback);
                            break;
                    }
                }
            });
            clp.setBehavior(behavior);
            // Also set the inset edge so that views can dodge the bar correctly
            clp.insetEdge = Gravity.BOTTOM;
        }

        mTargetParent.addView(mView);
    }

    mView.setOnAttachStateChangeListener(
            new BaseTransientBottomBar.OnAttachStateChangeListener() {
            @Override
            public void onViewAttachedToWindow(View v) {}

            @Override
            public void onViewDetachedFromWindow(View v) {
                if (isShownOrQueued()) {
                    // If we haven't already been dismissed then this event is coming from a
                    // non-user initiated action. Hence we need to make sure that we callback
                    // and keep our state up to date. We need to post the call since
                    // removeView() will call through to onDetachedFromWindow and thus overflow.
                    sHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            onViewHidden(BaseCallback.DISMISS_EVENT_MANUAL);
                        }
                    });
                }
            }
        });

    if (ViewCompat.isLaidOut(mView)) {
        if (shouldAnimate()) {
            // If animations are enabled, animate it in
            animateViewIn();
        } else {
            // Else if anims are disabled just call back now
            onViewShown();
        }
    } else {
        // Otherwise, add one of our layout change listeners and show it in when laid out
        mView.setOnLayoutChangeListener(new BaseTransientBottomBar.OnLayoutChangeListener() {
            @Override
            public void onLayoutChange(View view, int left, int top, int right, int bottom) {
                mView.setOnLayoutChangeListener(null);

                if (shouldAnimate()) {
                    // If animations are enabled, animate it in
                    animateViewIn();
                } else {
                    // Else if anims are disabled just call back now
                    onViewShown();
                }
            }
        });
    }
}

这个方法比较长,挑重点的看,主要有以下一些操作:

1)将包含了Snackbar 内容的mView 添加到容器mTargetParent 中,这里如果容器是CoordinatorLayout,将会设置Behavior,最终效果就是如果是CoordinatorLayout,Snackbar 会将布局内的其他内容顶上去,否则Snackbar 则会覆盖在布局其他内容上,当然都是在底部。

2)判断mView 是否已被parent 容器layout,如果是则根据是否需要进场动画调用不同方法,如果有动画,则调用animateViewIn 方法,效果就是Snackbar 从下到上逐渐进入,不需动画则直接调用onViewShown 方法。

3)如果mView 还没被layout,就给它设置个layout 改变的监听,监听到改变后执行的操作和第二步的相同。

animateViewIn 方法就是为mView 设置属性动画,这里就不展开了,当动画执行完之后会调用onViewShown 方法,onViewShown 的实现也比较简单,主要是这样:

1)调用SnackbarManager 的onShown 方法来通知SnackbarManager 当前的Snackbar 已经显示

2)如果用户添加了Snackbar 的显示隐藏回调BaseCallback,则会遍历mCallbacks 向callback 分发onShown 事件,这里实现的就是上文Snackbar 的使用中,提到的增加 “显示、消失时的事件”

到这里Snackbar 的显示就完事了,因为Snackbar 也是可以像Toast 那样自动隐藏,那么它是怎样实现的呢?请看下面一部分

3. 隐藏

上文提到,Snackbar 在显示完成后会调用SnackbarManager 的onShown 方法,这个onShown 方法实现简单,就是判断是不是当前SnackbarRecord,如果是,就调用scheduleTimeoutLocked 方法来控制SnackBar 在duration 时间之后隐藏。

scheduleTimeoutLocked 方法会先判断SnackBar 的duration 是不是LENGTH_INDEFINITE,如果是方法会直接返回,因为这种模式SnackBar 会一直存在直到手动dismiss 或者有新的SnackBar 到来才消失。如果不是LENGTH_INDEFINITE,就会根据duration 时长向mHandler 发送一个MSG_TIMEOUT的延时消息。

mHandler 在接收到这个消息之后会调用handleTimeout 方法,handleTimeout 方法会调用cancelSnackbarLocked 方法,cancelSnackbarLocked 方法接收两个参数,分别是SnackbarRecord 和一个表示事件类型的int 常量,事件类型有以下几种:

  • DISMISS_EVENT_SWIPE:通过滑动方式隐藏Snackbar

  • DISMISS_EVENT_ACTION:点击Snackbar 的action 按钮导致Snackbar 隐藏

  • DISMISS_EVENT_TIMEOUT:正常显示时间到了之后隐藏

  • DISMISS_EVENT_MANUAL:手动调用dismiss 方法隐藏Snackbar

  • DISMISS_EVENT_CONSECUTIVE:有新的Snackbar 到来,导致前一个隐藏

cancelSnackbarLocked 方法则是通过调用传入的SnackbarRecord 中的callback 的dismiss 方法来隐藏Snackbar。于是隐藏操作又回到了BaseTransientBottomBar 中,由mManagerCallback 处理,mManagerCallback 的dismiss 方法就是想Handler 发送了一个取消显示(MSG_DISMISS)的消息,Handler 在接收到消息后执行了hideView 方法

final void hideView(@BaseCallback.DismissEvent final int event) {
    if (shouldAnimate() && mView.getVisibility() == View.VISIBLE) {
        animateViewOut(event);
    } else {
        // If anims are disabled or the view isn't visible, just call back now
        onViewHidden(event);
    }
}

hideView 方法操作简单,就是根据是否需要动画来调用animateViewOut 还是onViewHidden 方法,animateViewOut 方法就是为mView 的隐藏设置退出的属性动画,animateViewOut 方法就不再展开了,在动画执行完会调用onViewHidden 方法,下面来看一个onViewHidden 方法

void onViewHidden(int event) {
    // First tell the SnackbarManager that it has been dismissed
    SnackbarManager.getInstance().onDismissed(mManagerCallback);
    if (mCallbacks != null) {
        // Notify the callbacks. Do that from the end of the list so that if a callback
        // removes itself as the result of being called, it won't mess up with our iteration
        int callbackCount = mCallbacks.size();
        for (int i = callbackCount - 1; i >= 0; i--) {
            mCallbacks.get(i).onDismissed((B) this, event);
        }
    }
    if (Build.VERSION.SDK_INT < 11) {
        // We need to hide the Snackbar on pre-v11 since it uses an old style Animation.
        // ViewGroup has special handling in removeView() when getAnimation() != null in
        // that it waits. This then means that the calculated insets are wrong and the
        // any dodging views do not return. We workaround it by setting the view to gone while
        // ViewGroup actually gets around to removing it.
        mView.setVisibility(View.GONE);
    }
    // Lastly, hide and remove the view from the parent (if attached)
    final ViewParent parent = mView.getParent();
    if (parent instanceof ViewGroup) {
        ((ViewGroup) parent).removeView(mView);
    }
}

这个方法主要做了以下工作:

1)调用SnackbarManager 的onDismissed 方法告诉SnackbarManager 这个Snackbar 已经消失

2)通知监听了显示、隐藏事件的callback 当前Snackbar 已消失,并调用它们的onDismissed 方法

3)将mView 从容器中移除

在SnackbarManager 的onDismissed 方法中调用了showNextSnackbarLocked 方法,显示Snackbar 队列的下一个Snackbar。到此,一个Snackbar 的隐藏过程也就结束了。至于手动调用Snackbar 的dismiss 方法,最终也是传递到SnackbarManager 的cancelSnackbarLocked 方法,这里就不再赘述。

4. 其他

Snackbar 使用SnackbarManager 进行Snackbar 的显示隐藏管理,SnackbarManager 内部以一种比较巧妙的方式来实现SnackbarRecord 的队列,即通过一个mCurrentSnackbar 和一个mNextSnackbar 来实现,通过show 和dismiss 的循环调用,来实现一种队列的效果。

你可能感兴趣的:(SnackBar 的使用及工作原理)