Sep 13

日历交互控件TabCalendar制作杂记

Lrdcq , 2016/09/13 12:13 , 程序 , 閱讀(3270) , Via 本站原創
在面向b端的app制作过程中,我们经常遇到以日期为单位进行交互和数据展示的展示性界面。大概是由于b端app多是密集的数据展示性质的,而一般的业务数据最常见的展示方式就是以天或者周为单位进行展示。因此我们曾经多次遇到,以日期为TabLayout的单位制作TabLayout+ViewPager的标准界面。然而,用TabLayout来呈现日期,其实有很大的缺陷的。

1.首先,仅仅把天罗列在一起,无法呈现出周的意味,需要添加多余的文字来描述星期信息。

2.另外由于TabLayout是单纯的横向滑动切换的,导致进行跨较长的日期进行查看的时侯操作异常困难。

3.同时,若在一旁添加点击展开日历控件进行选择,虽然可以解决问题,却会导致界面交互繁琐不具有连贯性。
因此,参考各方交互设计,实现了将TabLayout和Calendar组合在一起的控件:TabCalendar。如下是交互浏览视频:



如上所示,这个控件有如下优点:

1.在TabLayout对应的交互上,所有的日期按周为单位水平罗列,不需要左右滑动,在跨周处翻页tab会上下滚动切换。这样省去了水平操作狭窄的TabLayout麻烦,并且完整的呈现出周的信息。

2.可以下拉平滑呈现出完整的日历控件并且选择来切换日期。可以方便的跨周和跨月进行日期切换,滑动交互加上流畅切换使得用户体验远比独立操作的好。
就算再不济,对于app体验厨来说,这个控件也是解决这类问题的福音(既酷炫又实用)。

那么回到主题,这个交互并不算复杂的控件,由于涉及到的东西还是不少,还是遇到了不少棘手问题,消耗了我不少的开发时间。这篇文章的主题,就是回顾整个制作的过程和各种棘手的问题,为以后开发交互性控件提供解决思路。

界面拆解

看名字,我们需要的是一个TabLayout和一个完整的Calendar。不过仔细想想,TabLayout的交互操作基本上被重新定义了一次,所以可以免了。那个位置应该是一个日历控件的一栏,也就是一周。因此,首先准备好日历控件,和从中抽取衍生的周控件。
周控件可以随着viewpager的滑动在tab栏中上下滚动。考虑到大数量的周的呈现方式,因此那个位置实际放置一个listview/recyclerview比较好。
原本TabLayout和其下方的内容是并列展示的,但是考虑到如今这个tab可以往下拉,类似于下拉刷新控件的效果,因此把它作为一个容器更好。同样,日历呈现出来的过程中,背景有逐渐变深的效果,且希望独立接收滑动事件,因此这里也需要一个容器。因此最后整个几本的界面结构如图:(按照z轴方向排列)

點擊在新視窗中瀏覽此圖片

其中最关键的两部分是CalendarBar即TabLayout部分和CalendarBox即日历部分。开启状态下CalendarBox部分处于隐藏状态。

1.CalendarBar阶段,tab上7个触控区域可以方便的在本周切换日期并联动viewpager滑动切换,要跨周切换的话需要主动滑动viewpager使得tab上下切换。

2.切换阶段,开始CalendarBar接受触摸操作,下拉开始后CalendarBox显示并且组件展开,CalendarBar位于起上方同步移动并且渐隐藏,最后完整呈现出CalendarBox,CalendarBar移除界面。反方向,CalendarBox接受触控事件,同上效果。

3.CalendarBox阶段日历为单选控件,每进行一次选择,viewpager立刻进行切换,同时也顺便带动隐藏的CalendarBar进行切换。

可以看到,整个界面最关键的几个组件,即CalendarBar(RecyclerView + CalendarWeek),CalendarBox(Calendar)和用户提供了的ViewPager是相互强关联和制约的。

界面耦合

上一段将界面拆解完毕之后就是耦合界面了。由于说好了整个界面有3大部分,因此也是以他们3个为主进行耦合的。

CalendarBar-ViewPager

作为界面最初始部分和最常用的交互组合,这一对的耦合是非常关键的。
监听ViewPager的变化,我们首选
public interface OnPageChangeListener {
  void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
}

这一个回调方法就够了,其中positionOffset能准确的反馈回当前ViewPager的位置状态,这样我们就不用费尽心机的执着粘手和释放动画了,轻松加愉快。回到CalendarBar这边,明显需要处理的操作分为两部分,如果当前滑动的区域在同一周内,则进行的操作是移动当前的CalendarWeek中的那个圈圈,否则,需要移动整个RecyclerView的滑动位置。当然,这个RecyclerView早就被禁止滑动了所以这是唯一操作RecyclerView上下滚动的位置,不会出现问题。简化后的操作如下:
private ViewPager.OnPageChangeListener mOnPageChangeListener = new ViewPager.OnPageChangeListener() {
        private boolean isLastAnima = false;
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
            mCalendar.setTime(mStartDate);
            int sourceWeek = mCalendar.get(Calendar.DAY_OF_WEEK);
            mCalendar.add(Calendar.DATE, position);
            int week = mCalendar.get(Calendar.DAY_OF_WEEK);
            float offset = (float) week + positionOffset - 0.5f;

            int weekDay = (sourceWeek - 1 + position) / 7;

            XGCalendarWeek now = (XGCalendarWeek) mLinearLayoutManager.findViewByPosition(weekDay);
            if (offset > 6.5f) {
                if (!isLastAnima) {
                    now.setCirclePosition(6.5f);
                    XGCalendarWeek next = (XGCalendarWeek) mLinearLayoutManager.findViewByPosition(weekDay + 1);
                    next.setCirclePosition(0.5f);
                    isLastAnima = true
                }
                mLinearLayoutManager.scrollToPositionWithOffset(weekDay, -(int) (calendarBar.getMeasuredHeight() *(-6.5f + offset)));
            } else {
                mLinearLayoutManager.scrollToPosition(weekDay);
                now.setCirclePosition(offset);
                isLastAnima = false;
            }
        }
};

这样是通过ViewPager驱动CalendarBar的方式。方向的话只需要处理CalendarBar上的几个按钮,并且在按钮点击的时候根据当前状态对viewPager进行切换
v.setOnItemClickListener(item -> mViewPager.setCurrentItem(position * 7 - week + item + 1));

其它的事情就交给上面那段逻辑就可以啦。

RecyclerView重用view不绑定问题

在这个过程中遇到了一个问题,即当从一个week开始滚动到下一个week的时候,刚开始滚时就需要设定好下一个week的圈圈位于第一个位置,但是在这个时候或者一次渲染post后的下一时刻,有很大的概率下一个week压根没有准备好。同时由于recyclerView重用池的缘故,如果之前和之后它应用的位置是相通的,它并不是被调用onBindViewHolder重新进行数据绑定,这就使得从重用池取出的week控件有一定的可能性是没有被正确的初始化的。因此,采用的解决方式是每一次滑动事件的时候,如果上次尝试取得那个week控件失败,就再次尝试直到成功为止。

CalendarBox-ViewPager

在控件的日历形态,由于CalendarBox遮挡住了ViewPager,因此交互是单向的,只需要在Calendar发生点击事件的时候,直接操作ViewPager就可以了。ViewPager也会同时带动CalendarBar的变化,使得之后它们两者的数据交互更佳顺利。

CalendarBox-CalendarBar

它们两者的交互绑定主要是在切换的开始和结束时:切换开始,CalendarBar处理事件,一旦开始滑动,立刻将CalendarBox设置为当前选中的时间并且刷新。同理,在方向切换时,CalendarBox把自己重新设置为选中时间并且刷新。正好由于上一部分CalendarBar已经跟着ViewPager一起变化了所以不用担心。

先有界面还是先有数据问题

在上面两步的代码编写过程中,遇到的最大问题是日历控件高度不确定。我们需要获得日历控件的高度来设置切换动画即滑动的位置开始,但是在开始的那一瞬间设置了日历控件时间,日历控件并没有渲染完成,当然也无法获取正确的时间。因此,最粗暴的解决方法是在开始滑动触发设置时间之后,继续延迟滑动进行直到正确的获取到界面渲染完成后的高度。不过这个方案会导致滑动开始的时候有一个小顿所以否定了。最后采用的是预先渲染的方式,在界面外先对日历控件进行布局和渲染,在需要的时候再挪动要界面中来,这样几乎可以解决大部分类似的问题,不过对应的性能损失——暂时无视好了。

切换动画

组件之间另外一个比较关键的部分就是这个切换动画了。这次的切换分为3个主要数据:

1.一个是手指移动距离,因为我们要将日历展开,因此这个数据是展开后日历高度-bar高度,日历高度通过上文的‘先有界面还是先有数据’的解决方法来取得。

2.一个是日期移动距离,是效果中圈圈从bar的位置移动到实际日历中的位置的高度差。这一段这个高度也会遇到上文说的先渲染界面还是取数据的问题。由于这个数据的重要性,实际是强行调用日历中的方法预算出了这个数据,再经过:
int traget = (int) ((float) calendarTable.mPaddingTop + calendarTable.mCellHeight * (row + 2f) + calendarTable.mCellHeight / 2 - barHeight / 2);

得到了实际高度差。

3.界面展开距离,效果中界面上下是均匀展开的效果,因此需要通过前两个数据计算出界面展开的距离并作出对应反应。

说白了这是一堆数字计算的问题,各有各的写法吧。这里我们的写法是:
    private int processHeight = 0;

    private void processCalendar(float offset, float all) {
        if (offset > 0) offset = 0;
        if (offset < -all) offset = -all;
        float move = offset / all * traget;
        calendarTable.setTranslationY(move);
        calendarBar.setTranslationY(move + traget);
        calendarBar.setAlpha(-offset / all);
        calendarTableBack.setAlpha((all + offset) / all);
        processHeight = (int) (all + offset) + barHeight;
        calendarTableBox.invalidate();
    }

其中all是事先算好的数据1,traget是视线算好的数据2,吐出去的processHeight是最后用语渲染的数据3。要注意的是android中裁剪动画并没有什么很好的方式做,这里为了避免入侵代码,是在用于包装的CalendarBox上重新修改了一下渲染子view的方法如:
calendarTableBox = new FrameLayout(getContext()) {
            @Override
            protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
                boolean ans = super.drawChild(canvas, child, drawingTime);
                canvas.clipRect(0, 0, getMeasuredWidth(), processHeight);
                return ans;
            }
};

另外,还需要在出现和隐藏的动画开始和结束对两个界面进行数据和界面样式的初始化以保证切换准确进行。还有一点,除了粘手动画,还有释放后的松弛动画,采用的是最简单的倍率缓动方式。而刷新时间是直接紧接着post的,最佳情况就是一帧,一半来说这种计算量一帧大概没问题吧。实例如下:
private void showCalendarSmooth(float off, float all) {
        off *= 0.8;
        processCalendar(off, all);
        final float finalOff = off;
        if (off >= -10) {
            calendarTable.post(this::showCalendar);
        } else {
            calendarTable.post(() -> showCalendarSmooth(finalOff, all));
        }
}

其它问题

由于这是一个综合性较强的交互控件,除了上文中提到的,各个方面还涉及到一些别的乱七八糟的问题。

子界面布局

由于是一个像下拉刷新控件一样把用户界面作为子界面包在里边的viewGroup,因此需要自己来处理子界面。SwipeRefreshLayout的做法是完全实现了onLayout,但是其考虑到的只有下拉的圈圈和单个用户子界面,多个的话就直接抛错了。而我们的TabLayout考虑到布局方便,则是直接继承了FrameLayout先采用默认的布局方式布局。最后需要找到用户子布局,假设除去CalendarBar和CalendarBox的第一个布局就是了,需要将它的childTop往下移动一个bar的高度并且大小限定充满子布局大小。最后onLayout代码如下:
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        View user = getRootView();
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.equals(user)) {
                final int childTop = barHeight;
                final int childWidth = getMeasuredWidth();
                final int childHeight = getMeasuredHeight() - childTop;
                child.layout(0, childTop, childWidth, childTop + childHeight);
            }
        }
    }

另外还有一个问题,用户的界面一般是在xml中添加的,而我们的内部界面是在构造器初始化的时候内部添加的。而xml添加的界面往往后于构造器添加的界面,当然代码添加也是一样的。因此这种情况下android的布局默认会把后添加的界面渲染在更上面遮挡住我们的界面。因此解决方法是使用setChildrenDrawingOrderEnabled开启绘制排序选项,并且重新对应方法(简单处理逆序就可以了):
    @Override
    protected int getChildDrawingOrder(int childCount, int i) {
        return childCount - i - 1;
    }

下拉动画的正确做法

整个交互过程中其实涉及到了两种动画。一种是粘手动画,一种是释放动画。在viewPager上,这两种动画都已经帮我们完成了,week控件也只是简单的和viewPager的事件绑定联动一下就可以了。最关键在于下拉的处理。一次下拉操作分为3个阶段:

1.识别阶段,在单次touch事件流中,当手指下滑到一个阈值之前,不被识别为下拉,事件不拦截操作返回false。

1->2.到阈值时开始下拉,记录当前的手指位置并计算合计需要下拉的距离。

2.粘手阶段,通过当前手指位置和合计下拉距离计算下拉百分比,并进行变换操作界面刷新。要注意的是由于我们是安卓,所以交互上滑动操作被限制在0-100%中,可以拉出去的就是iOS了。

2->3.当手指释放的时候,首先进行判断,如果释放前滑动数据较快,则判断有fling手势,则往fling方向执行动画,否则对半分,靠近收起方向收起动画,靠近展开方向展开动画。

3.动画阶段,执行动画直到收起或者展开位置固定。

其中为了简单处理,还加了一个动画锁,当part3进行的时候,添加动画锁,此时无法进入part1,且通过isAnimation方法可以获取到当前状态。

在做part3的动画时,主要有两种做法:一种是货真价实的做Translation动画或者综合一下做属性动画。这样做的好处是通过动画对象对动画进行统一管理,如果不加锁的话可以很方便的取消和方向重置。另一个就是我现在做的无限递归到阈值。这样做简单清晰,运行时间灵活处理方便(比如移动距离短的话会立刻结束不会做无谓的动画),不过不方便取消所以必须加锁。

实际开发过程中,我的建议是,幅度/时间不固定,但是肯定不会很长的动画用后者,其它情况或者需要多个动画非同时触发的时候用前者。

界面比例性计算

这次界面布局中还遇到一个问题是,部分界面需要按照兄弟界面的大小来判断,而子界面的大小是根据父界面的大小按比例计算的,这样就陷入了一个延迟,我在初始化时期待获取兄弟界面渲染完成大小,而我自己同时也需要进行渲染。这里也有三种做法:

1.由于子界面的时机高宽位置数据在onLayout时已经获取到了,所以只要父级控件先布局了兄弟界面再布局了目标界面,我的onLayout就可以从兄弟界面中取得已经运算完成的数据并进行布局。这样就可以实际的避免冲突。当然问题也是显然的——如果兄弟界面没有暴露这些计算中的数据,要么入侵父级取出数据,要么就修改兄弟界面代码。

2.延迟渲染,不就是等么,因此可以期待被测量界面渲染完成后再开始或重新渲染目标界面,一般来说这样的操作只需要进行一次即初始化的时候就完成了,因此其中的时间成本几乎可以忽略不计。

3.父级布局时。和方法一相同,但是操作的代码移动到父级布局处。仔细想想就是应该的嘛,父级布局来决定子界面的位置大小不是应该的么。由于这里父级布局就是我们的TabCalendar,那就好办了,继续修改onLayout对特定child的布局代码,即在Framelayout一次布局完成后,单独抽出需要重新布局的CalendarBar和userView重新设置它们的高度即可。可惜这个具体的计算数据或者计算方法还是得从被测界面中取得。



由于这个组件引用了内部使用的库并且也是用于内部使用,所以无法开源,但是希望其中涉及到的总总交互控件开发问题可以作为有意义的文字记录下来。
logo