Android深入--Snackbar用法及源码解析

什么是Snackbar?

Google退出Matrial Design已经有一段时间了,其中有一款控件,叫做Snackbar。
那么它是做什么的呢?开发过Android的童鞋们应该对Toast不陌生了,Toast是一款用于在屏幕上显示提示信息的控件。而Snackbar,可以理解为Matrial Design风格的Toast,并且在功能上也有了一定的加强。

废话不多说,上图:
Android深入--Snackbar用法及源码解析_第1张图片
图中屏幕下方弹出的框就是Snackbar。其有着与Toast一样的特性(定时隐藏),也有一些新的特性(可按钮,不点击后隐藏等)。

使用Snackbar

Snackbar的用法并不复杂,使用时并不需要手动去创建Snackbar对象,其用法与Toast相似:

Snackbar.make(view,"显示的内容", Snackbar.LENGTH_LONG).show();

而如果要添加一个按钮并增加点击事件,只需要使用setAction方法即可。
Snackbar中的按钮被点击后,Snackbar会自动消失,并响应//点击事件:

Snackbar.make(view, "显示的内容", Snackbar.LENGTH_LONG)
                        .setAction("按钮", new View.OnClickListener() {
                            @Override
                            public void onClick(View v) {
                                //TODO: 点击按钮后的处理
                            }
                        }).show();

当然,Snackbar的每一种属性都可以单独设置,不过其构造方法是私有的,所以获取Snackbar对象的途径只有Snackbar.make方法:

//view可以为布局中任何控件或者跟布局。
public static Snackbar make(@NonNull View view, @NonNull CharSequence text, int duration)	
public class MainActivity extends AppCompatActivity {

    Snackbar snackbar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        FloatingActionButton fab = findViewById(R.id.fab);
        snackbar = Snackbar.make(fab, "显示的内容", Snackbar.LENGTH_LONG)
                .setAction("按钮", new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        //TODO: 点击按钮后的处理
                    }
                });
        fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                snackbar.setText("修改后的内容");
                snackbar.setAction("修改后的按钮文字", new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        //TODO: 点击按钮后的处理
                    }
                });
                snackbar.show();
            }
        });
    }
}

源码分析

那么了解了Snackbar的用法,大家可能依然会有许多疑问,比如:

  • Snackbar是如何显示到界面上的?
  • 多个Snackbar短时间内显示的时候排队逻辑时什么样的?
  • Snackbar的make方法为什么必须要传递一个view进去?
  • ····
    好的,那么我们就通过源码来了解一下上述疑问。

Snackbar如何显示在界面上

首先,我们需要知道,我们调用Snackbar的make方法后,再次调用show方法,即可将其显示到屏幕上。所以我们就从这两个方法入手:
make方法(ctrl+鼠标左键点进去):

    @NonNull
    public static Snackbar make(@NonNull View view, @NonNull CharSequence text, int duration) {
        ViewGroup parent = findSuitableParent(view);
        if (parent == null) {
            throw new IllegalArgumentException("No suitable parent found from the given view. Please provide a valid view.");
        } else {
            LayoutInflater inflater = LayoutInflater.from(parent.getContext());
            SnackbarContentLayout content = (SnackbarContentLayout)inflater.inflate(hasSnackbarButtonStyleAttr(parent.getContext()) ? layout.mtrl_layout_snackbar_include : layout.design_layout_snackbar_include, parent, false);
            Snackbar snackbar = new Snackbar(parent, content, content);
            snackbar.setText(text);
            snackbar.setDuration(duration);
            return snackbar;
        }
    }

从Snackbar的make方法可以看到,其通过我们传递的view来获取到了一个ViewGroup parent。之后又通过parent创建了LayoutInflater并取得了SnackbarContentLayout content。接下来内部通过new创建了一个Snackbar 实例,并且设置显示的文字及显示时长。最终将设置好的Snackbar返回。

看到这里我们可以确定每次调用Snackbar就会新建一个Snackbar对象。而其他代码的意义却不明朗。没关系,继续分析。

首先,我们看看第一行代码:ViewGroup parent = findSuitableParent(view);。可以推测,findSuitableParent方法通过view找到了一个ViewGroup。进入方法查看:

    private static ViewGroup findSuitableParent(View view) {
        ViewGroup fallback = null;

        do {
            if (view instanceof CoordinatorLayout) {
                return (ViewGroup)view;
            }

            if (view instanceof FrameLayout) {
                if (view.getId() == 16908290) {
                    return (ViewGroup)view;
                }

                fallback = (ViewGroup)view;
            }

            if (view != null) {
                ViewParent parent = view.getParent();
                view = parent instanceof View ? (View)parent : null;
            }
        } while(view != null);

        return fallback;
    }

emmm,由于编译器问题,暂时无法直观的看到一些值,这里做一下说明:16908290android.R.id.content。其为界面的跟布局的id,用户自己写的布局都在这个布局下。
该方法循环遍历传入View及其父布局,直至找到CoordinatorLayout或界面的跟布局。该方法中的CoordinatorLayout略过,SnackbarCoordinatorLayout配合可以达到滑动关闭Snackbar的效果,有兴趣的童鞋自行研究。我们目前默认该方法返回所传view所在界面的跟布局即可。

make方法中还有一个布局:SnackbarContentLayout content。其为Snackbar的布局。SnackbarContentLayout 代码片段如下:

public class SnackbarContentLayout extends LinearLayout implements ContentViewCallback {
    private TextView messageView;
    private Button actionView;
    private int maxWidth;
    private int maxInlineActionWidth;
    ···

可以看到,Snackbar的布局比较简单,继承自LinearLayout,内部包含了一个TextViewButton。根据其UI,我们可以确定TextView为其显示内容的控件,Button为点击按钮。

看到这里,make方法大致已经看完了,它主要是创建了一个Snackbar实例,入参为合适的值。并为其进行了基本数据的初始化。
那么,需要了解更多细节,我们还需要深入到构造方法中:

    private Snackbar(ViewGroup parent, View content, ContentViewCallback contentViewCallback) {
        super(parent, content, contentViewCallback);
        this.accessibilityManager = (AccessibilityManager)parent.getContext().getSystemService("accessibility");
    }

尼玛,除了传了一个值this.accessibilityManager,一个super完事。而Snackbar是继承自BaseTransientBottomBar的:

public final class Snackbar extends BaseTransientBottomBar

好吧,接着看BaseTransientBottomBar的构造方法:

protected BaseTransientBottomBar(@NonNull ViewGroup parent, @NonNull View content, @NonNull com.google.android.material.snackbar.ContentViewCallback contentViewCallback) {
        if (parent == null) {
            throw new IllegalArgumentException("Transient bottom bar must have non-null parent");
        } else if (content == null) {
            throw new IllegalArgumentException("Transient bottom bar must have non-null content");
        } else if (contentViewCallback == null) {
            throw new IllegalArgumentException("Transient bottom bar must have non-null callback");
        } else {
            this.targetParent = parent;
            this.contentViewCallback = contentViewCallback;
            this.context = parent.getContext();
            ThemeEnforcement.checkAppCompatTheme(this.context);
            LayoutInflater inflater = LayoutInflater.from(this.context);
            this.view = (BaseTransientBottomBar.SnackbarBaseLayout)inflater.inflate(this.getSnackbarBaseLayoutResId(), this.targetParent, false);
            this.view.addView(content);
            ViewCompat.setAccessibilityLiveRegion(this.view, 1);
            ViewCompat.setImportantForAccessibility(this.view, 1);
            ViewCompat.setFitsSystemWindows(this.view, true);
            ViewCompat.setOnApplyWindowInsetsListener(this.view, new OnApplyWindowInsetsListener() {
                public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
                    v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), insets.getSystemWindowInsetBottom());
                    return insets;
                }
            });
            ViewCompat.setAccessibilityDelegate(this.view, new AccessibilityDelegateCompat() {
                public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
                    super.onInitializeAccessibilityNodeInfo(host, info);
                    info.addAction(1048576);
                    info.setDismissable(true);
                }

                public boolean performAccessibilityAction(View host, int action, Bundle args) {
                    if (action == 1048576) {
                        BaseTransientBottomBar.this.dismiss();
                        return true;
                    } else {
                        return super.performAccessibilityAction(host, action, args);
                    }
                }
            });
            this.accessibilityManager = (AccessibilityManager)this.context.getSystemService("accessibility");
        }
    }

哇,这个方法中内容就比较多了。不过我们只需要带着 “Snackbar如何显示” 的问题去看代码,所以可以忽略掉其中一些内容。简化版如下:

    protected BaseTransientBottomBar(@NonNull ViewGroup parent, @NonNull View content, @NonNull com.google.android.material.snackbar.ContentViewCallback contentViewCallback) {
        ``````
        this.targetParent = parent;
        this.contentViewCallback = contentViewCallback;
        this.context = parent.getContext();
        LayoutInflater inflater = LayoutInflater.from(this.context);
        this.view = (BaseTransientBottomBar.SnackbarBaseLayout) inflater.inflate(this.getSnackbarBaseLayoutResId(), this.targetParent, false);
        this.view.addView(content);
        ``````
    }

可以看到,该方法主要时将入参赋值给全局变量。其中content添加到了this.view中。this.viewBaseTransientBottomBar.SnackbarBaseLayout。这个布局是一个Framelayout:

protected static class SnackbarBaseLayout extends FrameLayout

其内部设置了一些自己的监听,暂不考虑。

make方法看完了,接下来我们看一下show方法:

    public void show() {
        super.show();
    }

好吧,接着点进去:

    public void show() {
        SnackbarManager.getInstance().show(this.getDuration(), this.managerCallback);
    }

这里出现了另外一个类:SnackbarManager。它是Snackbar的管理类,管理其显示、隐藏、超时等逻辑。我们先看其show方法:

    public void show(int duration, SnackbarManager.Callback callback) {
        synchronized(this.lock) {
            if (this.isCurrentSnackbarLocked(callback)) {
                this.currentSnackbar.duration = duration;
                this.handler.removeCallbacksAndMessages(this.currentSnackbar);
                this.scheduleTimeoutLocked(this.currentSnackbar);
            } else {
                if (this.isNextSnackbarLocked(callback)) {
                    this.nextSnackbar.duration = duration;
                } else {
                    this.nextSnackbar = new SnackbarManager.SnackbarRecord(duration, callback);
                }

                if (this.currentSnackbar == null || !this.cancelSnackbarLocked(this.currentSnackbar, 4)) {
                    this.currentSnackbar = null;
                    this.showNextSnackbarLocked();
                }
            }
        }
    }

这里有几个方法我们需要了解一下它的作用:isCurrentSnackbarLockedscheduleTimeoutLockedisNextSnackbarLocked
isCurrentSnackbarLocked

private boolean isCurrentSnackbarLocked(SnackbarManager.Callback callback) {
    return this.currentSnackbar != null && this.currentSnackbar.isSnackbar(callback);
}

该方法在currentSnackbar不为空且传入的SnackbarManager.CallbackcurrentSnackbar 中的Callback是,返回true。
Callback是什么?往下看:

    private static class SnackbarRecord {
        final WeakReference callback;
        int duration;
        boolean paused;

        SnackbarRecord(int duration, SnackbarManager.Callback callback) {
            this.callback = new WeakReference(callback);
            this.duration = duration;
        }

        boolean isSnackbar(SnackbarManager.Callback callback) {
            return callback != null && this.callback.get() == callback;
        }
    }

    interface Callback {
        void show();

        void dismiss(int var1);
    }

它是SnackManager中定义的一个接口,在BaseTransientBottomBar中被实现,用于实现Snackbar的显示与消失。而其可以绑定在SnackbarRecord下。而show方法中,该对象实例赋值给了this.nextSnackbar

this.nextSnackbar = new SnackbarManager.SnackbarRecord(duration, callback);

看到这里,我们大概可以猜到,this.currentSnackbarthis.nextSnackbar主要是用于操作Snackbar排队显示逻辑的,这点先记下,后面的问题中探索,暂时略过。
我们看一下CallbackBaseTransientBottomBar中的实现:

    final Callback managerCallback = new Callback() {
        public void show() {
            BaseTransientBottomBar.handler.sendMessage(BaseTransientBottomBar.handler.obtainMessage(0, BaseTransientBottomBar.this));
        }

        public void dismiss(int event) {
            BaseTransientBottomBar.handler.sendMessage(BaseTransientBottomBar.handler.obtainMessage(1, event, 0, BaseTransientBottomBar.this));
        }
    };

可以看到,其回调方法都是向BaseTransientBottomBar中的handle发送消息,而handle的实现如下:

        handler = new Handler(Looper.getMainLooper(), new android.os.Handler.Callback() {
            public boolean handleMessage(Message message) {
                switch(message.what) {
                case 0:
                    ((BaseTransientBottomBar)message.obj).showView();
                    return true;
                case 1:
                    ((BaseTransientBottomBar)message.obj).hideView(message.arg1);
                    return true;
                default:
                    return false;
                }
            }
        });

其中调用了showView与hideView,很容易猜到它们就是显示与隐藏Snackbar的真正方法了。(绕了这么久终于找到了)。
我们看看showView方法:

    final void showView() {
        LayoutParams lp = this.view.getLayoutParams();
        if (lp instanceof androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams) {
            ``````
        }

        this.targetParent.addView(this.view);

        ``````

    }

(动画相关代码省略)可以看到,在这里,关键代码只有一行,即this.targetParent.addView(this.view);。将Snackbar的View添加到根布局中。
至此,Snackbar的显示逻辑看完············································了吗?
我们已经知道了Snackbar是动态添加到根布局的一个View,但是我们并没到看到哪里出发了Callback中的show回调。

秉承着做事有始有终的原则,我们接着看。回到SnackbarManagershow方法,其大意为:

  • 若show的Snackbar是当前的对象(即当前Snackbar正在显示的时候再次调用其show方法),则设置超时时间,并重置超时时间;

  • 若show的Snackbar不是当前对象,则判断排队逻辑后执行showNextSnackbarLocked方法。
    我们来看showNextSnackbarLocked方法:

    private void showNextSnackbarLocked() {
        if (this.nextSnackbar != null) {
            this.currentSnackbar = this.nextSnackbar;
            this.nextSnackbar = null;
            SnackbarManager.Callback callback = (SnackbarManager.Callback)this.currentSnackbar.callback.get();
            if (callback != null) {
                callback.show();
            } else {
                this.currentSnackbar = null;
            }
        }

    }

其大意是执行排队逻辑后,调用callback.show();。好的,至此,Snackbar的显示逻辑已经分析完成。

多个Snackbar短时间内显示的时候排队逻辑时什么样的

我们接下来分析第二个问题,这个问题在分析Snackbar显示逻辑的时候,与我们擦肩而过。我们回忆一下,显示逻辑中,SnackbarManagershow方法涉及了两个全局对象:

    private SnackbarManager.SnackbarRecord currentSnackbar;
    private SnackbarManager.SnackbarRecord nextSnackbar;

SnackbarRecord 的构造方法会绑定一个SnackbarManager.Callback

    private static class SnackbarRecord {
        final WeakReference callback;
        int duration;
        boolean paused;

        SnackbarRecord(int duration, SnackbarManager.Callback callback) {
            this.callback = new WeakReference(callback);
            this.duration = duration;
        }
        ···
    }

而这个SnackbarManager.Callback又来源于Snackbar的父类BaseTransientBottomBar。该回调接口掌管的Snackbar的显示与隐藏,而显示与隐藏的真正逻辑在BaseTransientBottomBar的handler中。我们重温一下SnackbarManagershow方法及其相关方法:

    public void show(int duration, SnackbarManager.Callback callback) {
        synchronized(this.lock) {
            if (this.isCurrentSnackbarLocked(callback)) {
                this.currentSnackbar.duration = duration;
                this.handler.removeCallbacksAndMessages(this.currentSnackbar);
                this.scheduleTimeoutLocked(this.currentSnackbar);
            } else {
                if (this.isNextSnackbarLocked(callback)) {
                    this.nextSnackbar.duration = duration;
                } else {
                    this.nextSnackbar = new SnackbarManager.SnackbarRecord(duration, callback);
                }

                if (this.currentSnackbar == null || !this.cancelSnackbarLocked(this.currentSnackbar, 4)) {
                    this.currentSnackbar = null;
                    this.showNextSnackbarLocked();
                }
            }
        }
    }
    
    private boolean isCurrentSnackbarLocked(SnackbarManager.Callback callback) {
        return this.currentSnackbar != null && this.currentSnackbar.isSnackbar(callback);
    }
    
    private boolean isNextSnackbarLocked(SnackbarManager.Callback callback) {
        return this.nextSnackbar != null && this.nextSnackbar.isSnackbar(callback);
    }

    private final Handler handler = new Handler(Looper.getMainLooper(), new android.os.Handler.Callback() {
        public boolean handleMessage(Message message) {
            switch(message.what) {
            case 0:
                SnackbarManager.this.handleTimeout((SnackbarManager.SnackbarRecord)message.obj);
                return true;
            default:
                return false;
            }
        }
    });

    void handleTimeout(SnackbarManager.SnackbarRecord record) {
        synchronized(this.lock) {
            if (this.currentSnackbar == record || this.nextSnackbar == record) {
                this.cancelSnackbarLocked(record, 2);
            }

        }
    }

    private boolean cancelSnackbarLocked(SnackbarManager.SnackbarRecord record, int event) {
        SnackbarManager.Callback callback = (SnackbarManager.Callback)record.callback.get();
        if (callback != null) {
            this.handler.removeCallbacksAndMessages(record);
            callback.dismiss(event);
            return true;
        } else {
            return false;
        }
    }

    private void scheduleTimeoutLocked(SnackbarManager.SnackbarRecord r) {
        if (r.duration != -2) {
            int durationMs = 2750;
            if (r.duration > 0) {
                durationMs = r.duration;
            } else if (r.duration == -1) {
                durationMs = 1500;
            }

            this.handler.removeCallbacksAndMessages(r);
            this.handler.sendMessageDelayed(Message.obtain(this.handler, 0, r), (long)durationMs);
        }
    }

方法看起来挺多的,这里对一些子方法一一做下解释:

  • isCurrentSnackbarLocked:
    判断currentSnackbar中的Callback是否与传入的Callback相同,是则返回true,否则返回false。
  • isNextSnackbarLocked:
    判断nextSnackbar中的Callback是否与传入的Callback相同,是则返回true,否则返回false。
  • handler :
    用于使Snackbar消失(通过调用handleTimeout
  • handleTimeout:
    用于过滤非法参数并进入cancelSnackbarLocked
  • cancelSnackbarLocked
    用于使Snackbar消失
  • scheduleTimeoutLocked:
    延时调用handle来使Snackbar消失。延时时间由duration控制。

好的,了解了上述代码,我们再次看一下SnackbarManagershow方法:
首先,代码最外层有一个if判断,其条件为this.isCurrentSnackbarLocked(callback)。那么通过刚刚对该方法的理解,我们可以释义为:若需要show的Snackbar的Callback与当前SnackManager绑定的Callback相同,则进入if,否则进入else。
我们首先分析if中的代码:

                this.currentSnackbar.duration = duration;
                this.handler.removeCallbacksAndMessages(this.currentSnackbar);
                this.scheduleTimeoutLocked(this.currentSnackbar);

可见,if中的代码做的事情就是重置了一遍使Snackbar延时消失的时间。可见如果对同一个Snackbar短时间内多次show的话,每次show方法都会重置其显示时间。

接下来看看重头戏,else中的代码:

                if (this.isNextSnackbarLocked(callback)) {
                    this.nextSnackbar.duration = duration;
                } else {
                    this.nextSnackbar = new SnackbarManager.SnackbarRecord(duration, callback);
                }

                if (this.currentSnackbar == null || !this.cancelSnackbarLocked(this.currentSnackbar, 4)) {
                    this.currentSnackbar = null;
                    this.showNextSnackbarLocked();
                }

nextSnackbarCallback与传入的相同,则更新nextSnackbarduration值(用于控制延时时间)。否则将传入的Callbackduration用于初始化nextSnackbar。可见执行到此处,nextSnackbar中的Callback一定与传入的Callback相同。
接下来,会判断this.currentSnackbar == null || !this.cancelSnackbarLocked(this.currentSnackbar, 4)。当currentSnackbar为空或关闭当前Snackbar失败时,会走进该分支执行关闭逻辑。否则不做任何操作。
emmm,看到这里,大家是不是有一个大大的疑惑:这里正常情况只做了关闭操作,那新的Snackbar在哪里打开呢?
我看代码的时候也产生了这样的疑惑,后来调试发现,在BaseTransientBottomBaronViewHidden里,有这么一行代码:SnackbarManager.getInstance().onDismissed(this.managerCallback);。而onDismissed方法的实现如下:

    public void onDismissed(SnackbarManager.Callback callback) {
        synchronized(this.lock) {
            if (this.isCurrentSnackbarLocked(callback)) {
                this.currentSnackbar = null;
                if (this.nextSnackbar != null) {
                    this.showNextSnackbarLocked();
                }
            }

        }
    }

可以看到,这里在关闭Snackbar后,紧接着会调用showNextSnackbarLocked。而showNextSnackbarLocked在显示逻辑中提到过,其主要作用是this.currentSnackbar = this.nextSnackbar;且展示Snackbar。这里顺带在currentSnackbar的Callback为空时清理掉了currentSnackbar

看到这里,Snackbar的排队逻辑差不多看完了:

  1. 排队最多两条,一条显示,一条赋值后立即关闭上一条并显示自身。
  2. 同一个Snackbar对象多次show,会重置其关闭时间。

emmm,看了这么多就得出这么两句话?WTF···
其实我们看源码,看的也是Google的设计理念。利用Callback来管理两个Snackbar,这样的思路,可以在我们自己的项目中使用。

Snackbar的make方法为什么必须要传递一个view进去

这个问题在显示逻辑中已经讨论过了,所以这里就不再赘述了哈,大家可以在显示逻辑中查看findSuitableParent方法的作用。

总结

不得不说,Google的程序猿写代码还是相当严谨的,其中可以看到各种兼容性判断。
Snackbar属于动态添加至布局中的UI,其添加的思路、代码结构、暴露的接口,都值得我们借鉴。
后续我会补上仿Snackbar思路写的自定义UI控件的Demo。

你可能感兴趣的:(Android技术)