Osheep

时光不回头,当下最重要。

Snackbar,你这是怎么了?

起因

Snackbar相信大家都不陌生,Material Design样式的消息通知,简洁的使用方式,相信很多人都已经替换掉Toast,改投Snackbar了。但就是这简简单单的一句代码:

Snackbar.make(view, "This is the snackbar.", Snackbar.LENGTH_SHORT).show();

最近却让我焦头烂额。到底是怎么回事呢?我这里写了个Demo来重现一些当时的场景:

    WindowManager windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
    ViewGroup root = new RelativeLayout(MainActivity.this);
    root.setBackgroundColor(getResources().getColor(R.color.colorPrimary));
    WindowManager.LayoutParams params = new WindowManager.LayoutParams();
    params.type = WindowManager.LayoutParams.TYPE_PHONE;
    params.format = PixelFormat.RGBA_8888;
    params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
    params.gravity = Gravity.START | Gravity.TOP;
    params.width = 400;
    params.height = 300;
    params.x = 70;
    params.y = 300;
    windowManager.addView(root, params);
    Snackbar.make(root, "This is the snackbar.", Snackbar.LENGTH_SHORT).show();

代码很简单,就是在一个悬浮框里显示一个Sanckbar,本想着平时经常使用的Snackbar在这也不会出什么问题,可现实却给了我重重的一击:

《Snackbar,你这是怎么了?》

NullPointerException

这是怎么回事?平时在Activity用的好好的Snackbar怎么一到WindowManager就不行了呢?

问题的源头

既然报错了,那我们先到NullPointerException的源头看它一看:

  private Snackbar(ViewGroup parent) {
        mTargetParent = parent;
        mContext = parent.getContext();  //就是这里报的NullPointerException

        ThemeUtils.checkAppCompatTheme(mContext);

        LayoutInflater inflater = LayoutInflater.from(mContext);
        mView = (SnackbarLayout) inflater.inflate(
                R.layout.design_layout_snackbar, mTargetParent, false);

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

看到这里我更迷惑了,这里的parent按道理应该是我们传入的view,怎么可能为空呢?难道这里的parent另有其人?看来我们还是得进入Snack.make()方法一探究竟:

    public static Snackbar make(@NonNull View view, @NonNull CharSequence text,
            @Duration int duration) {
        Snackbar snackbar = new Snackbar(findSuitableParent(view));
        snackbar.setText(text);
        snackbar.setDuration(duration);
        return snackbar;
    }

可以看到,我们传入的view是先经过一个findSuitableParent()方法调用的,听名字就知道肯定是这个方法捣的鬼。我们看看这个findSuitableParent()到底做了什么:

    private static ViewGroup findSuitableParent(View view) {
        ViewGroup fallback = null;
        do {
            if (view instanceof CoordinatorLayout) {
                // We've found a CoordinatorLayout, use it
                return (ViewGroup) view;
            } else if (view instanceof FrameLayout) {
                if (view.getId() == android.R.id.content) {
                    // If we've hit the decor content view, then we didn't find a CoL in the
                    // hierarchy, so use it.
                    return (ViewGroup) view;
                } else {
                    // It's not the content view but we'll use it as our fallback
                    fallback = (ViewGroup) view;
                }
            }

            if (view != null) {
                // Else, we will loop and crawl up the view hierarchy and try to find a parent
                final ViewParent parent = view.getParent();
                view = parent instanceof View ? (View) parent : null;
            }
        } while (view != null);

        // If we reach here then we didn't find a CoL or a suitable content view so we'll fallback
        return fallback;
    }

方法的逻辑很简单,就是循环遍历view的父视图,如果某个父视图是CoordinatorLayout或者已经追溯到了Activity的content视图就直接返回,查找期间如果某个view是FrameLayout就将其设为fallback,作为备用,当查找到视图顶端还没有找到合适的ViewGroup时就返回fallback变量。

看了这段代码,再看看我们之前传入的view,整个视图层级里,既没有CoordinatorLayout,也没有FrameLayout,又因为是直接使用WindowManager显示的,所以也没有content视图,真是要啥啥没有,自然最后的fallback为null,导致了后面的NullPointerException。

《Snackbar,你这是怎么了?》

Activity的视图层级
《Snackbar,你这是怎么了?》

直接使用WindowManger的视图层级

解决方案

相信看到这大家都明白了,我们平时在Activity里不管什么视图都可以使用Snackbar,是因为Activity本身有content视图可以给Snackbar使用,所以就算我们本身的视图层级里没有CoordinatorLayout或者FrameLayout,Snackbar也是可以正常使用的。但这在WindowManager中就不好使了,为保证Snackbar的正常使用,我们的视图层级必须包含CoordinatorLayout或者FrameLayout。这里我选择使用FrameLayout作为根视图,其他代码不变:

ViewGroup root = new FrameLayout(MainActivity.this);

果然这么一改,Snackbar可以正常显示了:

《Snackbar,你这是怎么了?》

正常运行

进一步探究

代码虽然成功运行了,可我还是有些疑惑,为什么Snackbar必须要使用CoordinatorLayout或者FrameLayout,其他的ViewGroup怎么就入不了Snackbar的法眼呢?想到这,我觉得我有必要继续深究藏在Snackbar身后的秘密。

你Snackbar不允许我使用其他的ViewGroup,我倒要看看我用一用会怎么样!

当然,这里普通的调用是没法做到的,会直接报NullPointerException,我们必须采取一些小手段:

    Constructor<Snackbar> snackbarConstructor = Snackbar.class
        .getDeclaredConstructor(ViewGroup.class);
    snackbarConstructor.setAccessible(true);
    Snackbar snackbar = snackbarConstructor.newInstance(root);
    snackbar.setText("This is the snackbar.");
    snackbar.setDuration(Snackbar.LENGTH_SHORT);
    snackbar.show();

这里我用反射直接构造一个Snackbar,将我们刚才的RelativeLayout根视图传入构造器。

运行!

《Snackbar,你这是怎么了?》

Snackbar显示出错

原来如此,看来Snackbar的视图的布局一定用了一些只有CoordinatorLayout和FrameLayout支持的属性,如果使用其他的ViewGroup,Snackbar的显示就会出现错误。那我们再看看Snackbar的xml文件到底写了些什么:

<view xmlns:android="http://schemas.android.com/apk/res/android"
      class="android.support.design.widget.Snackbar$SnackbarLayout"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_gravity="bottom"
      android:theme="@style/ThemeOverlay.AppCompat.Dark"
      style="@style/Widget.Design.Snackbar" />

可以看到这里Snackbar使用了android:layout_gravity="bottom"来保证Snackbar显示在视图底部,而这个layout_gravity属性只有CoordinatorLayout和FrameLayout支持,这就是为什么Snackbar不能使用其他ViewGroup的原因。

另一个问题

这个问题是在我解决上面的问题之后出现的:

《Snackbar,你这是怎么了?》

IllegalArgumentException

其实原因很简单,大家可以翻到前面再看一下Snackbar的构造方法,里面有一句:

ThemeUtils.checkAppCompatTheme(mContext);

这一句是检查Context是否使用的AppCompat主题,如果不是就会抛出IllegalArgumentException,因为当时是在Service里面创建的视图,root视图的Context自然也是用的Service,而Service是没有Theme的,于是就产生了这个异常。解决的方法也很简单:

ContextThemeWrapper wrapper = new ContextThemeWrapper(serviceContext, R.style.Theme_AppCompat);
ViewGroup root = new RelativeLayout(wrapper);

那为什么Snackbar一定要求AppCompat主题呢?其实也是因为xml文件内部使用了AppCompat的属性,我这里就不再展示了,如果感兴趣,可以自行查看。

结语

其实解决掉这个问题之后再回头看一看Snackbar的API介绍,解释的也还算清楚:

《Snackbar,你这是怎么了?》

Snackbar API介绍

但知其然,也要知其所以然,这样才算真正的弄知识。

点赞