可缩放时间轴和录像片段选择器的实现

6,194 阅读16分钟

最近的工作是做了两个自定义控件: ①可以缩放的时间轴 ②吸附在在时间轴上有两个滑动按钮的录像片段选择器

真机测试效果如下面的gif动画所示:

在此记录一下设计原理和踩过的坑。

时间轴

时间轴分为两部分轴,刻度轴和录像片段轴。刻度轴用来绘制时间刻度表盘,录像片段轴用来指示对应位置有没有录像存在。 时间轴的视觉效果如下:

其中下面有时间标注的刻度部分是刻度轴,上面灰色背景一段一段绿色的部分是录像片段轴,如下图的标注所示:

刻度轴

刻度轴的起始时间点、终止时间点、跨越长度、当前滑动到的时间点(即屏幕中间的游标指示的时间)都可以根据传入参数定制。刻度分为大刻度(关键刻度)和小刻度,大刻度要显示刻度和对应的时间文字,小刻度只显示刻度。刻度分为6档,根据缩放级别的不同(用户双指缩放或界面按钮缩放),把刻度打到不同位置。 比如,刚进入界面时刻度轴的刻度以默认档位显示,要在如图的位置打出刻度: 刻度轴1

当用户双指在屏幕上拉伸,或点击放大按钮,导致时间轴被放大时,一开始刻度打印方式不变(依然在这几个时刻打印文字),只是刻度间的距离随手指的拉伸距离而逐渐变大。当刻度间距离达到一定程度时,刻度打印方式改变,变成如下图的刻度样式: 刻度轴2

当用户双指在屏幕上收缩,或点击缩小按钮,导致时间轴被缩小时,一开始刻度打印方式不变(依然在这几个时刻打印文字),只是刻度间的距离随手指的收缩距离而逐渐变小。当刻度间距离小到一定程度时,刻度打印方式改变,变成如下图的刻度样式: 刻度轴3

我的实现方式是,通过继承view实现一个自定义view,在onMeasure()阶段把view宽度设置成所有刻度都绘制出来会占用的长度,即包括超出屏幕的实际长度。由于游标实际是不移动的,游标指示时间的改变是通过刻度轴的反向滑动来实现的,所以为了在时间轴滑动到尽头时游标能够指示到刻度轴尽头的时间,还需要在用户指定的长度基础上,在刻度轴的最左端和最右端分别额外增加屏幕一半宽度的空白区。原理图如下:

刻度档位信息

由于刻度轴根据缩放级别的不同,有6档不同的刻度显示方式(上面展示了3种),所以先把6档刻度信息写进map缓存,map中的每一个item记录这档刻度以下信息:

  • 一屏幕总共要显示的秒数(即可见区域包含的秒数)
  • 大刻度对应的秒数
  • 小刻度对应的秒数
  • 关键刻度文字的显示模式(DataFormat的pattern)

比如刻度轴1对应的这几个值就是6 60 60(一屏幕总共显示6个小时长度)、60 60(大刻度对应的都是整小时,即60 60的整数倍)、5 * 60(小刻度对应的都是5分钟的整数倍)、”HH:mm”(时间文字显示小时和分钟)。

然后,利用手机屏幕宽度screenWidth、用户指定的刻度轴总长度WHOLE_TIMEBAR_TOTAL_SECONDS、以及刚刚在map中设定的一屏幕总共要显示的秒数totalSecondsInOneScreen,就可以计算出某个刻度档位的时间轴总长度(像素):

viewLength = (int) ((float) screenWidth * WHOLE_TIMEBAR_TOTAL_SECONDS / (float) totalSecondsInOneScreen);
把算出的这个viewLength也缓存到map的对应item中,作为某一档的默认view宽度。

view宽度设定

在view第一次被初始化时,我们默认把刻度轴的样式设定在第3档的刻度样式,那么就从map中取出对应的刻度档位信息的时间轴总长度字段viewLength,然后通过为view指定新的LayoutParams来指定时间轴view控件的宽度,如下:

ViewGroup.LayoutParams params = getLayoutParams();
params.width = viewLength;
setLayoutParams(params);

这样仅仅是把view宽度指定为有刻度的部分的总长度。为了加上view两端无刻度的留白部分,我们要在onMeasure方法中做一些手脚:


  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {       
      setMeasuredDimension(measureWidth(widthMeasureSpec), VIEW_HEIGHT);
  }

      * 计算时间轴的宽度,左右都预留半个屏幕的宽度        */
  private int measureWidth(int widthMeasureSpec) {
      int measureMode = MeasureSpec.getMode(widthMeasureSpec);
      int measureSize = MeasureSpec.getSize(widthMeasureSpec);
      int result = getSuggestedMinimumWidth();
      switch (measureMode) {
          case MeasureSpec.AT_MOST:
          case MeasureSpec.EXACTLY:
              result = measureSize + screenWidth;
              break;
          default:
              break;
      }
      return result;
  }
  
这样的话,手机在测量view尺寸的时候就会把多出来的一个屏幕的宽度也算进去。

画刻度onDraw()

刻度怎么画是刻度轴实现的关键。思路是,我们在onDraw()中只绘制出屏幕上可见的那一部分,每次用户滑动或缩放时间轴,都通过invalidate()来重新回调onDraw()方法。 那么关键问题就是如何确定屏幕可见部分在view中的起始点、终止点分别是第几个像素。

不管时间轴view被用户缩放到了什么长度,它的总长度我们总是知道的(因为之所以能看到缩放效果,就是我们手动利用缩放指数factor计算新的长度,然后在onMeasure中重新手动赋值,后面会讲到),同时整个时间轴的跨度有多少秒我们也是知道的(用户需要通过参数指定view的起点、终点时间,类型为long),那么我们就可以计算出一秒钟对应多少个像素:

pixelsPerSecond = (float) (getWidth() - screenWidth) / (float) WHOLE_TIMEBAR_TOTAL_SECONDS;

由于用户在初始化时还指定了希望游标指示在哪个时刻上currentTimeInMillisecond,同时我们再利用这一档刻度标准规定的最小刻度间的时间间隔minTickInSecond(在第一部初始化map时指定),我们就可以求出屏幕可见区域最左端、最右端对应的是时间轴上的哪个时刻:

long forStartUTC = (long) (currentTimeInMillisecond / 1000 - screenWidth / pixelsPerSecond / 2 - minTickInSecond);
long endStartUTC = (long) (currentTimeInMillisecond / 1000 + screenWidth / pixelsPerSecond / 2 + minTickInSecond);

虽然我们知道了屏幕两端对应的时刻,但由于这个时刻很可能是不能被最小刻度间距minTickInSecond整除的,比如我们的最小刻度是5秒一档,但是当前最左端是12:05:02,那么这个地方就不应该打刻度,而应该继续往后早到第一个能被minTickInSecond整除的位置。

这里就有一个大坑。如果直接去用求模运算(%)找第一个刻度点,就会导致手机调整不同时区时,刻度被打在不同位置上的问题,比如我希望00:00、06:00、12:00、18:00才作为关键刻度打印时间文字,但是手机时区跳到北京时间(UTC+8)时,刻度会被打在08:00、14:00、20:00、02:00这几个位置上。更糟糕的是,当缩放到关键刻度只打印年月日而不打印时间的级别时,看起来游标指在2015-12-09的位置上,用户会以为这是北京时间2015-12-09 00:00:00,而实际他指向的是北京时间2015-12-09 08:00:00。

要解决这个问题,就要在找刻度、画刻度的时候考虑手机设定时区与UTC的时差,用UTC时间加上时差的结果来计算刻度的位置,但是时间轴上每个点代表的时间仍然是UTC时间,打印关键刻度文字传给DataFormatter的参数也仍然是UTC时间,因为DataFormat我们在默认不指定时区的情况下就是把UTC时间转成手机设定的本地时区时间来显示的。找屏幕要画出的第一个刻度的代码如下:

       *找出当前屏幕要画出的第一个刻度对应的时刻       */
     Calendar cal = Calendar.getInstance();
     int zoneOffsetInSeconds = cal.get(java.util.Calendar.ZONE_OFFSET) / 1000;

     long forStartUTC = (long) (currentTimeInMillisecond / 1000 - screenWidth / pixelsPerSecond / 2 - timebarTickCriterionMap.get(currentTimebarTickCriterionIndex).minTickInSecond);
     long endStartUTC = (long) (currentTimeInMillisecond / 1000 + screenWidth / pixelsPerSecond / 2 + timebarTickCriterionMap.get(currentTimebarTickCriterionIndex).minTickInSecond);

     long forStartLocalTimezone = forStartUTC + zoneOffsetInSeconds;
     long endStartLocalTimezone = endStartUTC + zoneOffsetInSeconds;

     long firstTickToSeeInSecondUTC = -1;
     for (long i = forStartLocalTimezone; i 
         if (i % timebarTickCriterionMap.get(currentTimebarTickCriterionIndex).minTickInSecond == 0) {
             firstTickToSeeInSecondUTC = i - zoneOffsetInSeconds;
             break;

         }

     }
     

其中timebarTickCriterionMap.get(currentTimebarTickCriterionIndex).minTickInSecond就是用来取到当前刻度档位的最小刻度时间间距。 这里为了避免最边缘的第一个和最后一个刻度绘制不出来,所以在forStartUTC和endStartUTC里都多给了一个最小刻度的余量,也就是代码第7行和第8行最后加减的一个最小刻度间距minTickInSecond。 这样,我们就找到了在本次onDraw()时,我们需要从view的第几个像素开始画刻度(view最左端的位置为第0个像素,大部分之间可能在屏幕之外很远的地方)。

然后从firstTickToSeeInSecondUTC的位置开始,以minTickInSecond为步进值,找每一个时刻,把每一个刻度也加上本地与UTC的时差,然后去对关键刻度间距keyTickInSecond和小刻度间距minTickInSecond分别求模运算(%),能被keyTickInSecond整除的就是关键刻度,只能被minTickInSecond整除的就是小刻度。如此,再调用canvas.drawRect()、canvas.drawText()分别画刻度和文字。

双指缩放

用户在触摸屏上双指缩放或点击放大、缩小按钮时,时间轴要产生缩放效果,原理很简单,就是拿缩放比例scaleFactor乘以当前整个个view宽度(不含一屏幕的空白区)最为新的宽度,然后拿这个宽度和每一档刻度标准中view整体长度字段进行比较,在合适的位置进行档位切换,或者在算出view宽度过大或过小时进行限制。总之,就是算出一个新的view宽度值,然后重设view的LayoutParams,onMeasure()和onDraw(),就可以看到缩放效果了。

   * 按照比例对时间轴进行缩放   * @param scaleFactor 缩放比例   */
 public void scaleTimebarByFactor(float scaleFactor){
     int newWidth = (int) ((getWidth() - screenWidth) * scaleFactor);

     if (newWidth > timebarTickCriterionMap.get(0).viewLength) {
         setCurrentTimebarTickCriterionIndex(0);
         newWidth = timebarTickCriterionMap.get(0).viewLength;

     } else if (newWidth < timebarTickCriterionMap.get(0).viewLength
             && newWidth >= getAverageWidthForTwoCriterion(0, 1)) {
         setCurrentTimebarTickCriterionIndex(0);

     } else if (newWidth < getAverageWidthForTwoCriterion(0, 1)
             && newWidth >= getAverageWidthForTwoCriterion(1, 2)) {
         setCurrentTimebarTickCriterionIndex(1);

     } else if (newWidth < getAverageWidthForTwoCriterion(1, 2)
             && newWidth >= getAverageWidthForTwoCriterion(2, 3)) {
         setCurrentTimebarTickCriterionIndex(2);

     } else if (newWidth < getAverageWidthForTwoCriterion(2, 3)
             && newWidth >= getAverageWidthForTwoCriterion(3, 4)) {
         setCurrentTimebarTickCriterionIndex(3);

     } else if (newWidth < getAverageWidthForTwoCriterion(3, 4)
             && newWidth >= timebarTickCriterionMap.get(4).viewLength) {
         setCurrentTimebarTickCriterionIndex(4);

     } else if (newWidth < timebarTickCriterionMap.get(4).viewLength) {
         setCurrentTimebarTickCriterionIndex(4);
         newWidth = timebarTickCriterionMap.get(4).viewLength;

     }

     ViewGroup.LayoutParams params = getLayoutParams();
     params.width = newWidth;
     setLayoutParams(params);
 }
 

录像片段轴

录像片段轴的绘制原理很简单,就是拿到一个List数据源,其中的每一个item都有录像片段的开始时间from、结束之间end,我们就通过换算,得到每个片段对应的开始像素位置、结束像素位置,然后在屏幕的对应位置用canvas.drawRect()画框就行了。 但是考虑到效率问题,如果录像片段数据很多,达到数千甚至数万段(比如很多都是短视频),那么一次性全部绘制出来是不现实的,还是只能绘制屏幕可见部分,然后在时间轴被缩放时,录像片段轴随之缩放,回调onDraw()方法,再次重绘屏幕可见区域。 问题就来了,在保证List中数据片段不重叠(即每一段的from到to与另一段的from到to都无交集)且有序排列的情况下(前一段的to小于后一段的from),如何快速找到哪一个item才是第一个应该被绘制出来的item?遍历list的方法显然太耗时,每次onDraw()都去遍历一遍的话,界面在滑动或缩放时会卡顿得无法接受。

我的解决办法如下: 步骤一:在外界向view设置录像片段轴的数据源List后,用我自己的类CloudRecordExistTimeClips来记录录像片段item信息,其中包括from、to的long值,还包括一个List字段coverDateZeroOClockList用来记录这个item跨越了哪几天,也就是 从startTimeInMillisecond到endTimeInMillisecond所涵盖的所有日期的00:00所对应的毫秒数。比如从2015-11-26 10:10:30到2015-11-29 19:12:55,那么coverDateZeroOClockList就记录“2015-11-26 00:00:00”、“2015-11-27 00:00:00”、“2015-11-28 00:00:00”、“2015-11-29 00:00:00”。具体实现方式如下:

public static class CloudRecordExistTimeClips {
      static long mostLeftDayZeroTime = Long.MAX_VALUE;
      static long mostRightDayZeroTime = -1;

      private long startTimeInMillisecond;
      private long endTimeInMillisecond;

              * 从startTimeInMillisecond到endTimeInMillisecond所涵盖的所有日期的00:00所对应的毫秒数。        * 如从2015-11-26 10:10:30到2015-11-29 19:12:55,        * 那么coverDateZeroOClockList就记录“2015-11-26 00:00:00”、“2015-11-27 00:00:00”、“2015-11-28 00:00:00”、“2015-11-29 00:00:00”        */
      private List coverDateZeroOClockList = new ArrayList<>();

      public CloudRecordExistTimeClips(long startTimeInMillisecond, long endTimeInMillisecond) {
          this.startTimeInMillisecond = startTimeInMillisecond;
          this.endTimeInMillisecond = endTimeInMillisecond;

          if (startTimeInMillisecond < mostLeftDayZeroTime){
              this.mostLeftDayZeroTime = startTimeInMillisecond;
          }

          if (endTimeInMillisecond > mostRightDayZeroTime){
              this.mostRightDayZeroTime = endTimeInMillisecond;
          }

          SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
          SimpleDateFormat zeroTimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

          String startTimeDateString = dateFormat.format(startTimeInMillisecond);
          String startTimeZeroTimeString = startTimeDateString + " 00:00:00";

          String endTimeDateString = dateFormat.format(endTimeInMillisecond);
          String endTimeZeroTimeString = endTimeDateString + " 00:00:00";

          try {
              Date startTimeZeroDate = zeroTimeFormat.parse(startTimeZeroTimeString);
              Date endTimeZeroDate = zeroTimeFormat.parse(endTimeZeroTimeString);

              long loopZeroDateInMilliseconds = startTimeZeroDate.getTime();
              while (loopZeroDateInMilliseconds 
                  coverDateZeroOClockList.add(loopZeroDateInMilliseconds);
                  loopZeroDateInMilliseconds = loopZeroDateInMilliseconds + SECONDS_PER_DAY * 1000;
              }
          } catch (ParseException e) {
              e.printStackTrace();
          }

      }

      public long getStartTimeInMillisecond() {
          return startTimeInMillisecond;
      }

      public long getEndTimeInMillisecond() {
          return endTimeInMillisecond;
      }

      public List getCoverDateZeroOClockList() {
          return coverDateZeroOClockList;
      }
  }
  

步骤二:根据每个录像片段对象CloudRecordExistTimeClips覆盖的日期,将每一个item缓存在一个全局Map中,map的key是某个日期00:00:00对应的long,value就是这个片段CloudRecordExistTimeClips对象。比如,一个片段的from是2015-12-08 11:32:22,to值是2015-12-10 11:32:22,那么这个对象就同时处在key为2015-12-08 00:00:002015-12-09 00:00:002015-12-10 00:00:00的map槽中。

步骤三:在onDraw()方法中,我们知道屏幕可见区域左边缘与view的交点对应的时间轴UTC时间(比如2015-12-09 15:30:12),那么我们就以当天0点的时间(即例子的2015-12-09 00:00:00)为key去取出当天第一个片段,从这个片段画起。如果这个key对应的values为null,那么就把key的值往后加一天,继续找,找到找到第一个片段开始画;或者key的时间大于屏幕右边缘的时间还没找到就停止寻找,因为说明屏幕可见区域中没有需要绘制的录像片段。

滑动与缩放

缩放事件毫无疑问直接使用android提供的缩放手势探测器ScaleGestureDetector来检测,直接可以拿到缩放比例参数scaleFactor,然后用上面提到的缩放函数来处理。 滑动可以在onTouchEvent(MotionEvent event)中自己处理ACTION_DOWN、ACTION_MOVE、ACTION_UP事件流,也可以直接使用android提供的手势探测器GestureDetector来处理,拿到滑动的距离deltaX,在原来view的layout位置的基础上计算滑动后应该处于的位置,然后调用layout(left, top, right, top + getHeight())来使view滑动,最后调用invalidate()来发起重绘。

这里踩到一个坑: ScaleGestureDetector和GestureDetector都要在onTouchEvent(MotionEvent event)函数的最前面通过截获event来进行手势处理。如果是一个缩放事件,ScaleGestureDetector已经处理了缩放手势,那么ACTION_DOWN、ACTION_MOVE、ACTION_UP以及其他事件流就不应该再继续在onTouchEvent()后面的ACTION_DOWN、ACTION_MOVE、ACTION_UP等分支中处理了,按照android文档说明,我使用scaleGestureDetector.onTouchEvent(event)的返回值来判断这个event是不是已近被处理过的缩放事件,如果是就直接return不再走后面的流程。 结果证明我很傻很天真,时间轴在被我双指缩放时同时也出现了意外的位移,说明scaleGestureDetector.onTouchEvent(event)的返回值是无效的。 google之后发现,很多开发者都遇到了同样的问题,说这应该是android sdk的一个bug,scaleGestureDetector.onTouchEvent(event)永远只会返回false。所以,在这里要先通过scaleGestureDetector.onTouchEvent(event)调用缩放手势探测器,然后用scaleGestureDetector.isInProgress()来判断本次事件是否被作为缩放手势处理了,代码如下:


  public boolean onTouchEvent(MotionEvent event) { 
      scaleGestureDetector.onTouchEvent(event);
      if (scaleGestureDetector.isInProgress()) {
          return true;
      }

      switch (event.getAction() & MotionEvent.ACTION_MASK) {
          case MotionEvent.ACTION_DOWN:
              ...
              break;

          case MotionEvent.ACTION_MOVE:
              ...
              break;

          case MotionEvent.ACTION_UP:
             	...
              break;
      }

      return true;
      }
      

录像片段选择器

通过两个滑动按钮来选择录像片段,两个滑动按钮之间的部分表示被选中的片段,两个滑动按钮上要显示对应的录像截图、时间文字提示,在滑动时、静止时根据获取成功与否显示不同的占位图。由于录像片段选择器上的时间信息是依附于时间轴控件的,所以当时间轴被滑动、缩放时,录像片段选择器也要同步更新。

很明显,这个控件简化一下就是有两个thumb的seekbar,所以就取github上找了一下两个按钮的seekbar控件,结果找到了anothem/android-range-seek-bar项目,它提供与android的seekbar视觉效果类似的双按钮seekbar控件。 我的改造工作主要就是重写onMeasure()、onLayout()、onDraw()、onTouchEvent()方法,最麻烦的就是onDraw()中绘制时对绝对坐标的计算。其计算方法与时间轴类似,甚至还简单许多,这里就不再重复写了。

这里的一个坑: 由于这个自定义view中需要用到android的ImageView(给Glide使用),所以这个自定义view就不能继承于view,而是继承于ViewGroup,这样就可以在初始化阶段向其中添加其他view了。然而调试的时候发现,onDraw()方法怎么都不会被调用。原来,ViewGroup默认只是用来布局,一般没有什么需要绘制的东西,所以系统默认就不调用它的onDraw()回调。如果要让ViewGroup的onDraw()方法被回调,有两种方法:

【方法1】让系统知道这个ViewGroup有可绘制的东西,比如在xml中定义这个ViewGroup的时候为它指定background属性。 【方法2】在代码中调用setWillNotDraw(false)强制要求调用onDraw()方法。