聊聊安卓App里的搜索

安卓App里,搜索是一个常用功能,是开发中高频需求。借着在公司技术分享的契机,我们一起聊聊那些年我们开发过的搜索栏。

本文分为两大部分:1. 搜索栏的产品逻辑 2. 搜索交互的代码实现。(如果对搜索栏产品逻辑不感兴趣,可以直接跳到第二部分阅读)

产品经理篇

没有系统的论述搜索栏的交互体系和生命周期,而是尝试通过回答以下三个问题来表达我对搜索栏的理解

什么时候需要搜索栏

对于用户来说,用户喜欢点击、长按、滑动、拖拽,而不喜欢输入;搜索需要用户打字输入。另一方面,并不是任何阶段的App都需要搜索栏

当列表不够用时

搜索是为了提供用户快速获得他们最想要的数据内容,当总的数据量很少时,直接列表展示所有数据即可。

比如一款旅行类的App,提供各个城市的景点,美食,商城的攻略和介绍。最初,这家初创的互联网公司只提供国内城市, 用户想要查看的各个城市的景点,直接通过滑动城市列表找寻

如果列表展示不够了,考虑在App里增加搜索栏。

当一种元素分类不够用时

随着这家旅游公司的成长,它能提供的国内城市由最初的十个不到,增多到的几十个,列表展示这几十个城市已经不合适了,这时候需要对城市进行分类。比如按城市名首字的字母表排序分类,比如按城市所在区域分类。通过分类,每个类别下只有若干个城市名,用户就能快速找到想要的数据。

如果一种分类展示不够用了,考虑在App里增加搜索栏

多种维度分类不够用时

这家旅游公司发展迅速,不仅能提供国内的城市,还提供国外城市,比如美国,欧洲等旅游城市,城市名增加到了上千个,这时候需要从之前的一种分类变成多种维度分类。按区域划分变成按五大洲分类,每个州按国家二级分类,每个国家按城市首字字母表三级分类。

如果多级分类不够用了,考虑在App里增加搜索栏

用户搜索时都在想什么

搜索速度

用户输入关键词查询,能接受的等待时间是有一个范围的,当超过这个范围,用户就会明显感受到等待,这个时间大体为一秒。另一方面,在搜索的时候吸引用户注意力能让用户的时间感知降低,比如引入加载的动画,立刻展示预置的通用图案和内容等

查看关心的信息

用户之所以搜索,就是想找到自己想要的搜索内容。这个搜索结果的构成,可以根据App的特性和下一个交互流程来决定。

还是以上文提到的旅游App为例,用户搜索城市,想要知道这个城市好玩的景点,好吃的美食,搜索的结果就需要体现这个城市的景点和美食。这家旅行App的的盈利方式之一是提供车票预订服务,那么搜索结果可以提供跳转到去往该城市的机票或火车票的下单页面。

搜索结束,用户接下来要做什么

一旦搜索结束,就意味着下一个交互流程的开始。给用户什么新的交互流程,根据两个方面吧:

延续搜索交互

分析App运营数据,分析用户高频的行为,用户搜索的心理活动,对于搜索结果,用户希望获得什么内容。通常是支持用户点击进入详情。比如城市名搜索完,点击进入城市详情页,详细展示该城市的景点和美食

提供新的流程

这部分需要结合App的产品定位和业务需求。

依然拿上文那家旅游公司为例,该公司后期提供了车票下单服务,提供用户和当地城市导游一对一聊天提问的服务。在App里,底栏Tab有四个:“城市”,“车票”,“聊天”,“我的”

这时候搜索到的城市结果展示,就需要提供点击跳转到该城市的车票下单页面,以及点击进入和当地导游聊天的页面

参考资料

搜索产品功能浅谈

设计搜索栏,你遵守这五条原则了吗?

程序员篇

有了搜索栏产品经理篇所述的认识,开发搜索栏的意义和方向和产品经理感同身受,对UI交互要求有了共鸣

Gmail搜索交互效果

谷歌邮箱App的搜索页交互效果

上面是谷歌邮箱App搜索的交互效果,如何实现这个UI效果?

实现这个效果,有三个步骤

  1. 有两层标题栏,前层是一个toolbar,背后的自己写的布局
  2. 当点击前层搜索,让后层的布局显示,前层的布局不显示
  3. 实施搜索动画,让背后的布局淡入显示出来,返回时则是上述的逆过程

步骤一

toolbar的使用有规范,自制的布局这里也不详诉了

步骤二

如果两个布局,一个显示,一个隐藏,通常会想到setVisible()方法来控制,这里提供另一种思路:View对象里,有一个方法bringToFront(),能改变子View在父容器位置顺序,改变了位置,就能改变前景的显示了。API文档对bringToFront的介绍如下:

bringToFront()
Change the view's z order in the tree, so it's on top of other sibling views.

步骤三

上图看到,点击搜索图标后,标题栏和状态栏从红色变成了白色,这里涉及到如何代码改变状态栏,淡入和后退键标题栏谈出用到了属性动画

核心代码如下:

private void reactionToClickSearchAction() {
    View childView = mRevealFrameLayout.getChildAt(0);
    childView.bringToFront();

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
        getWindow().setStatusBarColor(getResources().getColor(R.color.status_bar_gray));
    }

    ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(mLlSearchBar, "alpha", 0, 1);
    objectAnimator.setDuration(300).setInterpolator(new FastOutSlowInInterpolator());
    objectAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);
            mIsShowSearch = true;

            mEdSearch.requestFocus();
            KeyboardUtils.showSoftInput(mEdSearch, GMailMainActivity.this);
        }
    });

    objectAnimator.start();
}

@Override
public void onBackPressed() {
    if (mIsShowSearch) {
        mEdSearch.clearFocus();
        KeyboardUtils.hideSoftInput(mEdSearch, this);

        ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(mLlSearchBar, "alpha", 1, 0);
        objectAnimator.setDuration(200).setInterpolator(new FastOutSlowInInterpolator());
        objectAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);

                View childView = mRevealFrameLayout.getChildAt(0);
                childView.bringToFront();
            }
        });
        objectAnimator.start();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
            getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            getWindow().setStatusBarColor(getResources().getColor(R.color.colorPrimaryDark));
        }
        mIsShowSearch = false;

        return;
    }
    super.onBackPressed();
}

Google Play搜索交互效果

Google Play搜索页

上面是Google Play子目录页搜索的交互效果,这个交互效果怎么实现呢?

会发现它和Gmail的交互很像,不同的之处有两点:

  1. 点击搜索的动画不同,Google Play的动画不是淡入而是像水波纹一样展开的
  2. 状态栏颜色不改变

如何让一个布局水波纹似地展开呢?
从API 21 android5.0 棒棒糖版本开始,安卓提供了一个方法ViewAnimationUtils.createCircularReveal()

static Animator createCircularReveal(View view, int centerX, int centerY, float startRadius, float endRadius)
Returns an Animator which can animate a clipping circle.

那么在5.0以下的版本怎么办,除了自己实现这种效果外,github上已经有人开源了ozodrukh/CircularReveal

核心代码如下:

/**
 * 点击搜索
 */
private void reactionToClickSearchAction() {
    mShowSearchToolbar = true;

    View childView = mRevealFrameLayout.getChildAt(0);
    childView.setVisibility(View.VISIBLE);
    childView.bringToFront();

    int centerX = childView.getRight();
    int centerY = childView.getBottom() / 2;
    Animator circularReveal = ViewAnimationUtils.createCircularReveal(childView, centerX, centerY, 0, childView.getWidth());
    circularReveal.setDuration(300).setInterpolator(new LinearInterpolator());
    circularReveal.start();

    circularReveal.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);

            reactionToCover(true);

            mEdSearch.requestFocus();
            KeyboardUtils.showSoftInput(mEdSearch, GplayMainActivity.this);
        }
    });
}

private boolean reactionToBackPressed() {
    if (mShowSearchToolbar) {
        KeyboardUtils.hideSoftInput(mEdSearch, this);

        View childView = mRevealFrameLayout.getChildAt(0);
        childView.bringToFront();

        int centerX = childView.getLeft();
        int centerY = childView.getBottom() / 2;
        Animator circularReveal = ViewAnimationUtils.createCircularReveal(childView, centerX, centerY, 0, childView.getWidth());
        circularReveal.setDuration(300).setInterpolator(new DecelerateInterpolator());

        circularReveal.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);

                reactionToCover(false);
            }
        });
        circularReveal.start();

        mShowSearchToolbar = false;
        return true;
    }
    return false;
}

public void reactionToCover(boolean isDark){
    if(isDark) {
        mFrameBodyCover.setVisibility(View.VISIBLE);
    }else{
        mFrameBodyCover.setVisibility(View.GONE);
    }
}

小结

上述代码已提交到Github:SearchBarDemo,欢迎star。

欢迎关注CodeThings

阅读资料

深入浅出搜索系列之(一)- 初识搜索

Material Design中全新的动画

深入理解Android L新特性之 页面内容&共享元素过渡动画

Github:ozodrukh/CircularReveal

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 129,017评论 19 550
  • 在读这篇文章之前,请先仔细回想一下你看书与写读书笔记的场景。 在一张不大不小的书桌上,你的左手边放着一本封皮很好看...
    L小姐在路上阅读 853评论 15 11
  • 文玩从疯狂到堕落,很多人都说是因为经济啊,反腐啊,其实都不是,就是因为一个字"假"。我现在很少逛大集,近日文玩大集...
    丑小虫阅读 759评论 1 3
  • 删除东西,丢掉身上一些不必要的东西。 当这个想法迸出来的时候,是在一杯浓缩美式咖啡灌下肚辗转反侧睡不着的胡思乱想。...
    崽崽哩阅读 22评论 0 1