不要干扰用户的操作流-足球app kicker 的用户体验与技术分享

英文原文:DON'T INTERRUPT THE USER'S FLOW

转载务必注明出处:http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0830/3382.html 

按照我的观点,用户体验是一个非常重要的话题,然而有时候并没有得到足够的重视。在这篇文章中,我想向你展示如何使用RecyclerView去创建一个不会打断用户操作流的用户体验。

我非常幸运能在Tickaroo公司的一个富有才华的团队工作,我们正在为kicker开发与维护安卓和ios的app,kicker  是欧洲最重要的足球杂志之一(虽然主要语言是德语)。这个app主要展示关于足球的新闻(不过也有关于其他体育的),现场得分&现场跟踪(消息推动),数据,画廊等等之类的东西。几天前我们发布了kicker android app 的一个更新,里面加入了一个新的交互特性:赛事提示(tip game,以下用英语代替,英文很难找到对应的中文意思)(赌球,猜足球,猜球或者其他什么名字,每个地方叫法不同)。因此我们的想法是:kicker app的用户去预测足球赛事的结果,如果他们猜中结果的话就得分。

当打开kicker app你会看见一个显示了不同item的RecyclerView,赛事结果和赛事预告夹在其他的新闻文章中间。第一个问题就是我们如何集成新的赌球功能?然后的问题是,用户该如何提交一个赛事的“tips”(尝试猜测结果)?我们可以不那么为难自己(从开发者的角度),直接开启一个新的Activity来显示赛事列表,在这里用户可以提交“tips”(尝试猜测结果)。但是我们决定实现两种“模式”,因为我们已经在RecyclerView中显示了最新结果与赛事预告:“普通”模式中,用户可以看到赛事预告或者最新赛事结果,而“tip”模式中,用户可以提交一个tip(猜测结果),然后在赛事结束的时候查看自己是否猜中。因此最终的效果是这样的:

与打开一个新的Activity不同,我们决定通过点击tippen”和schließen”按钮在两个模式之间切换,切换的时候使用翻转动画。

我们是如何实现的呢?很明显我不能分享整个源代码,但是我会给你一些我们面临的问题与挑战。

Flip items

正如你在前面视频中看到的,我们让RecyclerView的item显示了动画效果。我们先从一个包含了普通模式与tip模式的子layout开始,如这样:

<FrameLayout>
    <LinearLayout
      android:id="normalMode"
      android:layout_width="match_parent"
      android:layout_height="match_parent">
        ...
    </LinearLayout>
    <LinearLayout
      android:id="tipMode"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:visibility="invisible">
        ...
    </LinearLayout>
 </FrameLayout>

然后RecyclerView中的ViewHolder将引用“normalMode” 和“tipMode”布局。翻转视图不过就是一个rotationX()动画,如下(别忘了把visibility从“visible”设置到“invisible”,同理反之):

public void animateToTipMode(TipViewHolder holder, int delay){
 int duration = 100;
 // Flip the "normalMode" View "out"
 ViewCompat.animate(viewHolder.normalMode)
          .rotationX(90) // Animate from 0 to 90
          .setDuration(duration)
          .setStartDelay(delay)
          .setListener(new ViewPropertyAnimatorListenerAdapter(){
              @Override public void onAnimationEnd(View view) {
                viewHolder.normalMode.setVisibility(View.INVISIBLE);
              }
            })
          .start();
  // Flip the "topMode" View "in"
  ViewCompat.setRotationX(holder.tipMode, -90);
  ViewCompat.animate(viewHolder.tipMode)
           .rotationX(0) // Animate from -90 to 0
           .setDuration(duration)
           .setStartDelay(delay)
           .setListener(new ViewPropertyAnimatorListenerAdapter(){
               @Override public void onAnimationEnd(View view) {
                 viewHolder.normalMode.setVisibility(View.VISIBLE);
               }
             })
           .start();
}

因此基本上,动画的运行是:view退出动画是从0 到 90,而进入动画是从-90 到 0。而翻转动画的波浪执行效果是通过添加一个开始延迟(start delay)并在item上递增这个延迟来实现的。因此我们要做的只是收集触发在两个模式之间切换的button上面的item。

TipViewHolder是包含了普通模式与tip模式子布局的ViewHolder。在ListView时代,我们只需通过ListView.getChildAt(index)遍历子view来完成,但是RecyclerView的内部以不同的方式处理它的子view(LayoutManager)。

所以,RecyclerView.getChildAt(index)并不会跟ListView一样以预期的顺序返回子view。使用RecyclerView的时候你也需要ViewHolder。ViewHolder不只是一个用于持有子view引用并减少indViewById()操作的普通类,ViewHolder知道更多关于自己是如何在父亲RecyclerView内部被使用的事情。因此我们可以使用ViewHolder.getAdapterPosition()来查询一个ViewHolder当前的adapter position。我们使用它来得到button(开始翻转动画的)的adapter  position(看看前面的视频你会发现:有一个按钮用于开始翻转动画,以在“普通模式”和“tip模式”之间切换)。我们知道我们的app中,所有的赛事(TipViewHolder)都在可点击按钮之上,因此我只需从按钮的position向上遍历adapter数据集合。我们还假设在两组赛事或者tip之间至少有一个其他类型的ViewHolder,因此我们可以使用 instanceof TipViewHolder来决定动画的波浪传递是否应该停止。

public void onSwitchToTipModeClicked(ViewHolder buttonViewHolder){
   int adapterPosition = buttonViewHolder.getAdapterPosition();
   int index = adapterPosition - 1;
   int delay = 70;
   while (index >= 0){
     ViewHolder vh = recyclerView.findViewHolderForAdapterPosition(index);
     if (! (vh instanceof TipViewHolder) ){
        break;
     }
     animateToTipMode((TipViewHolder) vh, delay * (adapterPosition - index));
     index --;
   }
 }

我们使用viewHolder.getAdapterPosition() 来决定开始位置(position)并检查在给定index位置的ViewHolder是否是TipViewHolder类型。我们可以通过RecyclerView.findViewHolderForAdapterPosition(index)来获取指定adapter position 的ViewHolder。注意,如果某个adapter  index没有相应的ViewHolder与之对应,将会返回null。这是因为这个adapter index位置的item此时在屏幕上是不可见的(item在RecyclerView可见矩形区域之外,用户必须滚动才能使那个item回到可见矩形区域)。还需注意的是对null使用instanceof 某个类返回的是false。

开发者在创建UI的时候还需留意的另一个事情是,动画可以用于掩盖app正在加载数据的事实。在我们的app中,当用户从“普通模式”切换到“tip模式”的时候,我们执行了一个http请求在后台加载前一次预测的结果(如果有的话),通常,我们的用户不会注意到数据正在加载,因为我们没有显示加载的提示。我们利用波浪式的动画所花费的时间来加载数据。这给用户一个app非常快的印象,因为没有显示加载提示在视觉上打断用户。如果用户的网速很慢又如何呢?好吧,我们在波浪动画结束的时候显示一个加载提示。

缺点

这个方法的缺陷是什么呢?这个布局文件包含了一个笨重的树结构,因为它既显示了“普通模式”又显示了“tip模式”。如果大多数用户都是用的都是Galaxy S6这样的高端设备或者至少是Nexus 4级别,这不是什么问题。不幸的是我们的用户中,类似于Samsung Galaxy S3 Mini 这样的平价设备才是最常用的。这意味着什么呢?庞大的view结构会导致比较差的滚动性能,在低端设备上尤其明显。而猜球功能只是一个主要被重度用户使用的可选功能,我个人估计大概有30%的用户会参与猜球。这意味着70%的用户可能因为猜球功能会面临滚动性能问题,即便他们根本没有使用。因此,我们决定用一个不同的方式来实现翻转动画:不再用一个布局包含两种模式(普通模式与tip模式),取而代之的是把它们分割成两个布局文件,并在adapter中定义两个view type。

声明:如果你的布局已经足够完善了这可能比较让人恼怒,前面的解决办法其实也能工作。但是,对于我们来说,使用不同的view type来保证在普通设备上也能达到可接受的滚动性能是有必要的。

让我来给你解释如何实现前面描述的办法中所实现的波浪效果的动画:幸运的是,RecyclerView有足够强大的扩展功能。我们实现了一个自定义的ItemAnimator来执行翻转动画(仍然是之前的rotationX动画,从 0 到 90 以及 -90 到 0)。写一个自定义的ItemAnimator并不是火箭发明一样的高端科学,不过它还是要花点时间深入这个方法才能让ItemAnimator很好的工作。你可能会对为什么我们需要一个ItemAnimator感到困惑。一个ItemAnimator可以让你定义在item插入,移动或者从RecyclerView Adapter移除时运行的动画。因此我们我做的就是修改adapter的数据集合。办法和之前的解决方案一样:我们遍历adapter数据集合,但是这次不再是检查ViewHolder并手动开始翻转动画,我们从adapter 数据集合中移除掉 Game items ( “普通模式”下的item),用 Tip item(“tip模式”下的item)代替。这跟ItemAnimator有什么关系呢?你可能还不知道,RecyclerView提供了两个方法notifyItemRangeRemoved() 与 notifyItemRangeInserted():

public void onSwitchToTipModeClicked(ViewHolder buttonViewHolder, List dataset){
  int MIN_DEVICE_YEAR = 2012;
  int adapterPosition = buttonViewHolder.getAdapterPosition();
  int index = adapterPosition - 1;
  int bottomIndex = index;
  int invisibleStartIndex = - 1;
  List<Tip> tipsToInsert = new ArrayList<>();
  while (index >= 0){
    Object item = dataset.get(index);
    if (! (item instanceof Game) ){
       break;
    }
    Game game = (Game) item;
    RecyclerView.ViewHolder viewHolder =
            recyclerView.findViewHolderForAdapterPosition(index);
    if (viewHolder == null){
      // Element not visible on screen, so we can replace the item directly
      dataset.set(index, game.getTip());
      if (invisibleStartIndex == -1){
        invisibleStartIndex = index;
      }
    } else {
      // Element is visible on screen
      viewHolder.itemView.setTag(R.id.adapterIndexWorkaround, adapterPosition); // Workaround
      tipsToInsert.add(0, game.getTip()); // Keep the order
    }
    index--;
  }
  int topIndex = invisibleStartIndex >=  0 ? invisibleStartIndex :  index + 1 ;
  // Remove old visible "Game" items
  for (int i = bottomIndex; i >= topIndex; i--) {
      dataset.removeItem(i); // Reason for workaround
  }
  if (deviceYear >= MIN_DEVICE_YEAR){
    adapter.notifyItemRangeRemoved(topIndex, tipsToInsert.size()); // Triggers remove flip animation
  }
  // Insert visible "Tip" items
  dataset.addAll(topIndex, tipsToInsert);
  if (deviceYear >= MIN_DEVICE_YEAR){
    adapter.notifyItemRangeInserted(topIndex, tipsToInsert.size()); // Triggers insert flip animation
  } else {
    // Without animations because of low-end device
    adapter.notifyDataSetChanged();
  }
}

让我们来讨论下上面的代码:和第一个解决办法类似我们我们先从切换两种模式的button的adapter position开始。既然我们可以假设button在RecyclerView中是可见的(不然它无法被点击是吧?)而且该组所有的game item都在button之上,所以我们只需从下到上遍历adapter 数据集合。我们使用item instanceof Game来决定这组赛事是在什么地方结束的。(我们的app中一组赛事之间总是至少有一个不同的item)。然后我们检查是否viewHolder == null,这表示这个item在RecyclerView中是不可见的。这样的话我们就可以直接用相应的tip item来替换这个game(赛事) item。

我们还标注了第一个不可见item开始的地方,把列表位置(position)存储在invisibleStartIndex中。如果item可见,我们把相应的tip添加到tipsToInsert。我们还必须使用setTag()来保持原有adapter的position,setTag可以让你把数据存储在一个view内部的Map<Integer, Object>中(推荐使用R.id.something作为key)。为什么要做这个事情,因为我们想让ItemAnimator以波浪的形式翻转item。要正确的安排好每一个item的时间,知道index是很重要的。

每一个ViewHolder都可以通过viewHolder.getAdapterPosition()知道自己的索引值(index),但是因为我们已经从adapter的数据集合中移除了item,所以这个方法返回的是-1。通常 ItemAnimator 可以使用viewHolder.getOldPosition()返回之前的position(在item从adapter数据集合移除之前)。但是我们实际情况是所有的viewHolder.getOldPosition()返回的都是同一值。为什么?因为我们是在for循环中手动移除每个item的(因为list缺少list.adAll这样一次能移除多个item的方法)。因此,我们的ItemAnimator必须使用那种替代方案来合理安排翻转动画。最后,你可能注意到了MIN_DEVICE_YEAR。我不记得到底是什么问题了(这些东西我该记录下来的),但是在很旧的设备上会遇到这样的问题,如果动画运行的时间过长,RecyclerView会达到一个疯了的内部状态(这会导致崩溃)。为了避免这个问题我们使用Facebook的Device Year Class库来检测旧设备,直接调用adapter.notifyDataSetChanged()不在旧设备上运行动画。

提交tip

接下来你可能会想知道如何提交一个tip。我们最开始通过一个数字选择对话框来实现。但是问题在于你必须打开一个赛事的对话框来提交一个tip,然后关闭该对话框再打开下一个来提交tip。这绝对和我们想要的用户体验是相违背的。因此我们的改进是在同一个对话框中传入可以预测的赛事列表,避免每一个赛事都去打开和关闭对话框:

 

Guessing in a rush

但是,对话框仍然会干扰用户体验。因此我想到了使用划动手势的办法。这让用户可以当场预测结果而不需要显示一个对话框来干扰用户。看看如下的最终结果:

划动item到右边用户可以增加主场球队的得分,而划动item到左边则可以增加客场球队的得分。如果你之前使用过MotionEvent,那么这实现起来很简单。一言蔽之:每个ViewGroup提供了两个在分发MotionEvent期间被调用的方法:在boolean onInterceptTouchEvent() 中,你可以得到一些MotionEvent来决定那个ViewGroup是否应该拦截并消费void onTouchEvent()中接下来所有的MotionEvent。这就类似于在酒店中订了一瓶好酒。服务员给你

带来了一瓶以及一个酒杯并提供了被订酒的一点点。在你闻了闻,检查了颜色,在酒杯里摇了摇,然后你决定是否留下这酒(在onInterceptTouchEvent()中返回true或者false)。如果是,你消费掉这酒(onTouchEvent()))。RecyclerView的使用和这没什么区别。幸运的是,你不需要继承RecyclerView与重写那些方法因为那些方法已经用于检测滚动与fling事件。RecyclerView提供了一个OnItemTouchListener来拦截item上的MotionEvent。

public abstract class TipSwipeListener implements RecyclerView.OnItemTouchListener {
  private final int threshold;
  private float xStart = 0;
  private float yStart = 0;
  private TipViewHolder startViewHolder;
  private int increaseMaxSwipeDistance;
  @Override public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
    final int action = MotionEventCompat.getActionMasked(e);
    // Touching somewhere in the RecyclerView
    if (action == MotionEvent.ACTION_DOWN) {
      xStart = e.getX();
      yStart = e.getY();
      // Determine the View we touch
      View startTouchedView = rv.findChildViewUnder(e.getX(), e.getY());
      if (startTouchedView == null) {
        return false;
      }
      // Determine the ViewHolder that has been touched
      RecyclerView.ViewHolder vh = rv.getChildViewHolder(startTouchedView);
      if (vh != null && vh instanceof TipViewHolder) {
        startViewHolder = (TipViewHolder) vh;
      }
      return false;
    }
    // Move the finger on the screen (not released finger from screen yet)
    if (action == MotionEvent.ACTION_MOVE && startViewHolder != null) {
      float xDif = Math.abs(xStart - e.getX());
      float yDif = Math.abs(yStart - e.getY());
      if (xDif >= threshold && xDif > yDif) {
        // finger is moving horizontally
        return true;
      }
      // finger is moving vertically
      return false;
    }
    // releasing finger from screen
    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
      reset();
    }
    return false;
  }
  @Override public void onTouchEvent(RecyclerView rv, MotionEvent e) {
    final int action = MotionEventCompat.getActionMasked(e);
    float xDif = e.getX() - xStart;
    if (action == MotionEvent.ACTION_MOVE) {
      if (startViewHolder.getTippView() != null) {
        if (xDif < 0) {
          startViewHolder.translateXAwayTeam(0);
          startViewHolder.translateXHomeTeam(Math.max(xDif, -increaseMaxSwipeDistance));
        } else if (xDif > 0) {
          startViewHolder.translateXHomeTeam(0);
          startViewHolder.translateXAwayTeam(Math.min(xDif, increaseMaxSwipeDistance));
        } else {
          startViewHolder.getTippView().translateXHomeTeam(0);
          startViewHolder.getTippView().translateXAwayTeam(0);
        }
      }
    } else
      // Released or Canceled swipe gesture
      if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
        if (xDif < -increaseMaxSwipeDistance) {
          onIncreaseAwayScoreSwipe(); // Swiped far enough
        } else if (xDif > increaseMaxSwipeDistance) {
          onIncreaseHomeScoreSwipe(); // Swiped far enough
        } else {
          animateToStartState(xDif);
        }
      }
  }
  private void onIncreaseAwayScoreSwipe() {
    // Increment away team's score
    ...
    reset();
  }
  private void onIncreaseHomeScoreSwipe() {
    // Increment home team's score
    ...
    reset();
  }
  private void animateToStartState(float xDif) {
    // animate the X translation back to 0
    ...
    reset();
  }
  private void reset() {
    xStart = 0;
    yStart = 0;
    startViewHolder = null;
  }
}

绝大多数代码本身就很明了。我们只是首先在onInterceptTouchEvent()中使用recyclerView.findChildViewUnder(x, y) 检查用户是否正在触摸一个tip item,然后再使用recyclerView.getChildViewHolder(view)检查相应的ViewHolder是否是一个TipViewHolder 。接下来检查用户是否正在把手指划到左边或者右边。注意onInterceptTouchEvent() 被调用了多次我们需要检查不只一个MotionEvent(参数)来决定用户是否左移或者右移他的手指。如果手指在x轴上移动距离超过一个临界值,则我们返回true,申明我们想继续在onTouchEvent()中消费这个手势。

在onTouchEvent()中,我们根据用户手指所划动的距离与方向设置translationX属性。increaseMaxSwipeDistance是用户可以移动的最大距离,即显示预测结果区域宽度的一半。那么划动的时候+1是从哪里来的呢?这只是一个一直隐藏在预测结果视图下的TextView。在x轴上移动view,TextView变为可见。实际上,为了保持布局结构的扁平,在我们的app中我们重写了onDraw() 直接在一个自定义父布局的画布上绘制+1。

添加ItemTouchListener到RecyclerView是非常简单的:recyclerView.addOnItemTouchListener()。最后,你可能会想这里的横向滑动是否可以用在ViewPager中。其实这比我想象的简单。跟所有其他的ViewGroup一样,ViewPager可以通过调用ViewPager.requestDisallowInterceptTouchEvent(boolean)来让自己的孩子申明自己不想让父亲截断touch event。所以设置requestDisallowInterceptTouchEvent()为true的好地方就是onInterceptTouchEvent()中,在返回true之前,当手指离开的时候被调用。顺便,如果你不知道你的view树中是否有ViewPager,你可以从下到上使用view.getParent()递归遍历view树(只做一次,无需每次移动手指的时候都调用)。 译者注:怎么扯到ViewPager了,有毛关系。。。

划动方式的缺点是用户无法减少预测得分,因为x轴上两个方向都被使用了。因此为了此用例与那些手指不灵敏的用户,我们决定保持Dialog的方式。

另外注意:使用AdapterDelegates(组合由于继承)我们能够能够再不重写代码的情况下为整个app的所有RecyclerView带来普通模式与tip模式切换的翻转动画。

总结

越少打断用户操作流,用户体验就越好。遵循此原则的另外两个很棒的例子是Google Photos 和 Inbox。通过一些手势与动画,这些app为他们的用户提供了极致的用户体验。不管你是否喜欢我们翻转动画的方法,我希望你能明白ItemAnimator和OnTouchListener 可以和RecyclerView用在一起创造一个对用户干扰尽可能小的用户体验。顺便说一句,kicker app 在真机上看起来要比youtube视频上好很多。

资源

ps:文中所描述的用户体验要归功于整个Tickaroo团队而不是我一个人。我们一起开发出了这个用户体验。我们的设计师提出了最初的想法,iOS开发者也在原型设计的时候加入了一点东西。

来自:UI实验室