com.acbelter.scheduleview.ScheduleView.java Source code

Java tutorial

Introduction

Here is the source code for com.acbelter.scheduleview.ScheduleView.java

Source

/*
 * Copyright 2014 acbelter
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.acbelter.scheduleview;

import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.TypedArray;
import android.database.DataSetObserver;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.EdgeEffectCompat;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
import android.view.*;
import android.widget.AdapterView;
import android.widget.OverScroller;
import android.widget.TextView;

import java.text.SimpleDateFormat;
import java.util.*;

// TODO Remove non visible views
// TODO Add tests
public class ScheduleView extends AdapterView<ScheduleAdapter> {
    private static final boolean DEBUG = false;
    private static final String TAG = ScheduleView.class.getSimpleName();
    private static final long MILLIS_IN_HOUR = 60 * 60 * 1000;
    // The distance between the first time mark and the top of the view
    private int mInternalPaddingTop;
    // The distance between the last time mark and the bottom of the view
    private int mInternalPaddingBottom;
    // The distance between adjacent time marks
    private int mTimeMarksDistance;

    private LayoutInflater mInflater;
    private ScheduleAdapter mAdapter;

    private View mTimeMark;
    private int mTimeMarkHeight;

    private int mTimeZoneOffset;
    private int mHourHeight;
    private long mStartTime;
    private long mEndTime;

    private int mViewWidth;
    private int mViewHeight;
    // Width of the schedule items
    private int mItemWidth;
    private int mBackgroundHeight;
    private int mOldBackgroundHeight = -1;
    // The difference between the height of background and the height of the view
    private int mDeltaHeight;

    private int mItemPaddingLeft;
    private int mItemPaddingRight;
    private ArrayList<String> mTimeMarks;
    // Top of the list position
    private int mListY;
    private int mScrollDirection;

    private Rect mClipRect;
    private Rect mClickedViewBounds;

    private DataSetObserver mDataSetObserver;

    private GestureDetector mGestureDetector;
    private GestureDetector.OnGestureListener mGestureListener;
    private OverScroller mOverScroller;

    private EdgeEffectCompat mTopEdgeEffect;
    private EdgeEffectCompat mBottomEdgeEffect;

    private boolean mTopEdgeEffectActive;
    private boolean mBottomEdgeEffectActive;

    private ActionMode mActionMode;
    private boolean mIsActionMode;
    private ActionMode.Callback mActionModeCallback;

    private Set<Long> mSelectedIds;

    public ScheduleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ScheduleView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs);
        mClipRect = new Rect();
        mClickedViewBounds = new Rect();
        mSelectedIds = new HashSet<Long>();
        mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

        mTopEdgeEffect = new EdgeEffectCompat(context);
        mBottomEdgeEffect = new EdgeEffectCompat(context);

        mDataSetObserver = new DataSetObserver() {
            @Override
            public void onChanged() {
                super.onChanged();
                removeAllViewsInLayout();
                requestLayout();
            }

            @Override
            public void onInvalidated() {
                super.onInvalidated();
                removeAllViewsInLayout();
                requestLayout();
            }
        };

        init(context);

        setVerticalScrollBarEnabled(true);
        setHorizontalScrollBarEnabled(false);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ScheduleView, defStyle, 0);
        try {
            if (a != null) {
                DisplayMetrics dm = context.getResources().getDisplayMetrics();
                mInternalPaddingTop = (int) a.getDimension(R.styleable.ScheduleView_internalPaddingTop,
                        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, dm));
                mInternalPaddingBottom = (int) a.getDimension(R.styleable.ScheduleView_internalPaddingBottom,
                        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, dm));
                mTimeMarksDistance = (int) a.getDimension(R.styleable.ScheduleView_timeMarksDistance,
                        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 40, dm));
                mItemPaddingLeft = (int) a.getDimension(R.styleable.ScheduleView_itemPaddingLeft,
                        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, dm));
                mItemPaddingRight = (int) a.getDimension(R.styleable.ScheduleView_itemPaddingRight,
                        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, dm));
                initializeScrollbars(a);
            }
        } finally {
            if (a != null) {
                a.recycle();
            }
        }

        // Draw the background even if no items to display
        setWillNotDraw(false);

        mActionModeCallback = new ActionMode.Callback() {
            @Override
            public boolean onCreateActionMode(ActionMode mode, Menu menu) {
                mode.getMenuInflater().inflate(R.menu.menu_context, menu);
                mIsActionMode = true;
                return true;
            }

            @Override
            public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
                return false;
            }

            @Override
            public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
                if (item.getItemId() == R.id.delete_items) {
                    deleteSelectedItems();
                }
                return false;
            }

            @Override
            public void onDestroyActionMode(ActionMode mode) {
                clearSelection();
                mIsActionMode = false;
                mActionMode = null;
                invalidate();
            }
        };
    }

    /**
     * @param startDayHour 0-23
     * @param endDayHour 0-23
     * @param timeZoneOffset GMT hours offset
     * @param format24
     */
    public void configure(int startDayHour, int endDayHour, int timeZoneOffset, boolean format24) {
        if (startDayHour < 0 || startDayHour > 23) {
            throw new IllegalArgumentException("Incorrect startDayMinutes.");
        }

        if (endDayHour < 0 || endDayHour > 23) {
            throw new IllegalArgumentException("Incorrect endDayMinutes.");
        }

        if (startDayHour == endDayHour) {
            throw new IllegalArgumentException("Incorrect arguments: startDayMinutes=endDayMinutes.");
        }

        mTimeZoneOffset = timeZoneOffset;
        mStartTime = startDayHour * MILLIS_IN_HOUR;
        mEndTime = endDayHour * MILLIS_IN_HOUR;
        fillTimeMarksTitles(startDayHour, endDayHour, format24);
        initTimeMarkView(format24);
    }

    private TimeZone getTimeZone() {
        if (mTimeZoneOffset > 0) {
            return TimeZone.getTimeZone("GMT+" + mTimeZoneOffset);
        }
        if (mTimeZoneOffset < 0) {
            return TimeZone.getTimeZone("GMT-" + Math.abs(mTimeZoneOffset));
        }
        return TimeZone.getTimeZone("GMT+0");
    }

    private void fillTimeMarksTitles(int startDayHour, int endDayHour, boolean format24) {
        SimpleDateFormat timeFormat;
        if (format24) {
            timeFormat = new SimpleDateFormat("HH:mm");
        } else {
            timeFormat = new SimpleDateFormat("hh:mm aa", Locale.ENGLISH);
        }

        Calendar c = Calendar.getInstance();
        c.set(Calendar.HOUR_OF_DAY, startDayHour);
        c.set(Calendar.MINUTE, 0);

        mTimeMarks = new ArrayList<String>();
        while (c.get(Calendar.HOUR_OF_DAY) != endDayHour) {
            mTimeMarks.add(timeFormat.format(new Date(c.getTimeInMillis())));
            c.add(Calendar.HOUR_OF_DAY, 1);
            if (c.get(Calendar.HOUR_OF_DAY) == endDayHour) {
                mTimeMarks.add(timeFormat.format(new Date(c.getTimeInMillis())));
            }
        }
    }

    private void deleteSelectedItems() {
        for (Long id : mSelectedIds) {
            mAdapter.removeForId(id);
        }
        mAdapter.notifyDataSetChanged();
        mSelectedIds.clear();
        finishActionMode();
    }

    private void init(Context context) {
        if (!isInEditMode()) {
            mOverScroller = new OverScroller(context);
            mGestureListener = new GestureDetector.SimpleOnGestureListener() {
                @Override
                public boolean onDown(MotionEvent e) {
                    if (DEBUG) {
                        Log.d(TAG, "onDown() y=" + mListY);
                    }

                    releaseEdgeEffects();
                    mOverScroller.forceFinished(true);
                    return true;
                }

                @Override
                public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
                    if (DEBUG) {
                        Log.d(TAG, "onFling() y=" + mListY);
                    }

                    // Fling isn't needed
                    if (mDeltaHeight < 0) {
                        return true;
                    }

                    mScrollDirection = velocityY > 0 ? 1 : -1;
                    mOverScroller.fling(0, mListY, 0, (int) velocityY, 0, 0, -mDeltaHeight, 0);
                    if (!awakenScrollBars()) {
                        ViewCompat.postInvalidateOnAnimation(ScheduleView.this);
                    }
                    return true;
                }

                @Override
                public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                    for (int i = 0; i < getChildCount(); i++) {
                        getChildAt(i).setPressed(false);
                    }

                    mListY -= (int) distanceY;
                    recalculateOffset();

                    positionItemViews();

                    if (mListY == 0) {
                        mTopEdgeEffect.onPull(distanceY / (float) getHeight());
                        mTopEdgeEffectActive = true;
                    }
                    if (mListY == -mDeltaHeight) {
                        mBottomEdgeEffect.onPull(distanceY / (float) getHeight());
                        mBottomEdgeEffectActive = true;
                    }

                    if (!awakenScrollBars()) {
                        invalidate();
                    }

                    return true;
                }

                @Override
                public void onLongPress(MotionEvent e) {
                    if (DEBUG) {
                        Log.d(TAG, "onLongPress() y=" + mListY);
                    }

                    View child;
                    for (int i = 0; i < getChildCount(); i++) {
                        child = getChildAt(i);
                        child.getHitRect(mClickedViewBounds);
                        if (mClickedViewBounds.contains((int) e.getX(), (int) e.getY())) {
                            if (!mIsActionMode) {
                                mActionMode = startActionMode(mActionModeCallback);
                                mIsActionMode = true;
                            }

                            if (!child.isSelected()) {
                                mSelectedIds.add(mAdapter.getItemId(i));
                                child.setSelected(true);
                            } else {
                                mSelectedIds.remove(mAdapter.getItemId(i));
                                child.setSelected(false);
                            }

                            if (mSelectedIds.isEmpty()) {
                                finishActionMode();
                            }

                            invalidate();
                            return;
                        }
                    }
                }

                @Override
                public boolean onSingleTapUp(MotionEvent e) {
                    if (DEBUG) {
                        Log.d(TAG, "onSingleTapConfirmed() y=" + mListY);
                    }

                    View child;
                    for (int i = 0; i < getChildCount(); i++) {
                        child = getChildAt(i);
                        child.getHitRect(mClickedViewBounds);
                        if (mClickedViewBounds.contains((int) e.getX(), (int) e.getY())) {
                            if (!mIsActionMode) {
                                OnItemClickListener callback = getOnItemClickListener();

                                if (callback != null) {
                                    callback.onItemClick(ScheduleView.this, child, i, mAdapter.getItemId(i));
                                }
                            } else {
                                if (!child.isSelected()) {
                                    mSelectedIds.add(mAdapter.getItemId(i));
                                    child.setSelected(true);
                                } else {
                                    mSelectedIds.remove(mAdapter.getItemId(i));
                                    child.setSelected(false);
                                }

                                if (mSelectedIds.isEmpty()) {
                                    finishActionMode();
                                }

                                invalidate();
                            }
                            break;
                        }
                    }
                    return true;
                }
            };

            mGestureDetector = new GestureDetector(context, mGestureListener);
        }
    }

    private void setSelection() {
        for (int i = 0; i < mAdapter.getCount(); i++) {
            if (mSelectedIds.contains(mAdapter.getItemId(i))) {
                getChildAt(i).setSelected(true);
            } else {
                getChildAt(i).setSelected(false);
            }
        }
    }

    private void clearSelection() {
        for (int i = 0; i < getChildCount(); i++) {
            getChildAt(i).setSelected(false);
        }
        mSelectedIds.clear();
    }

    private void recalculateOffset() {
        if (mDeltaHeight > 0) {
            // Background height is more than screen height
            if (mListY < -mDeltaHeight) {
                mListY = -mDeltaHeight;
            }
        } else {
            // Screen height is more than background height
            mListY = 0;
        }

        if (mListY > 0) {
            mListY = 0;
        }
    }

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    private int getCurrentVelocity() {
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            return (int) mOverScroller.getCurrVelocity();
        }
        return 0;
    }

    private static class ViewHolder {
        public TextView time;
        public View timeLine;
    }

    private void initTimeMarkView(boolean format24) {
        if (mTimeMark == null) {
            if (format24) {
                mTimeMark = mInflater.inflate(R.layout.time_mark_24, null);
            } else {
                mTimeMark = mInflater.inflate(R.layout.time_mark_12, null);
            }

            ViewHolder holder = new ViewHolder();
            holder.time = (TextView) mTimeMark.findViewById(R.id.time);
            holder.timeLine = mTimeMark.findViewById(R.id.time_line);

            mTimeMark.setTag(holder);
        }
    }

    @Override
    public ScheduleAdapter getAdapter() {
        return mAdapter;
    }

    @Override
    public void setAdapter(ScheduleAdapter adapter) {
        if (mAdapter != null && mDataSetObserver != null) {
            mAdapter.unregisterDataSetObserver(mDataSetObserver);
        }

        mAdapter = adapter;
        if (mAdapter != null) {
            mAdapter.registerDataSetObserver(mDataSetObserver);
        }

        clearSelection();
        finishActionMode();
        removeAllViewsInLayout();
        requestLayout();
        invalidate();
    }

    private void finishActionMode() {
        if (mIsActionMode) {
            mActionMode.finish();
            mActionMode = null;
            mIsActionMode = false;
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        if (mAdapter != null) {
            mAdapter.unregisterDataSetObserver(mDataSetObserver);
        }
    }

    @Override
    public View getSelectedView() {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    @Override
    public void setSelection(int i) {
        throw new UnsupportedOperationException("Not supported yet.");
    }

    @Override
    public Parcelable onSaveInstanceState() {
        ScheduleState ss = new ScheduleState(super.onSaveInstanceState());
        ss.listY = mListY;
        ss.backgroundHeight = mBackgroundHeight;
        ss.isActionMode = mIsActionMode;
        ss.selectedIds = mSelectedIds;
        return ss;
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        if (!(state instanceof ScheduleState)) {
            super.onRestoreInstanceState(state);
            return;
        }

        ScheduleState ss = (ScheduleState) state;
        super.onRestoreInstanceState(ss.getSuperState());

        mListY = ss.listY;
        mOldBackgroundHeight = ss.backgroundHeight;
        mIsActionMode = ss.isActionMode;
        mSelectedIds = ss.selectedIds;

        if (mIsActionMode) {
            mActionMode = startActionMode(mActionModeCallback);
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mViewWidth = MeasureSpec.getSize(widthMeasureSpec);
        mViewHeight = MeasureSpec.getSize(heightMeasureSpec);
        setMeasuredDimension(mViewWidth, mViewHeight);

        mTimeMark.measure(
                MeasureSpec.makeMeasureSpec(mViewWidth - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));

        ViewHolder holder = (ViewHolder) mTimeMark.getTag();
        mItemWidth = holder.timeLine.getMeasuredWidth() - mItemPaddingLeft - mItemPaddingRight;
        mTimeMarkHeight = mTimeMark.getMeasuredHeight();
        mHourHeight = mTimeMarkHeight + mTimeMarksDistance;

        mBackgroundHeight = calculateBackgroundHeight();
        mDeltaHeight = mBackgroundHeight - mViewHeight;

        // Correct scroll position if another time mark layout is used
        if (mOldBackgroundHeight != -1) {
            float ratio = (float) mBackgroundHeight / mOldBackgroundHeight;
            mListY = (int) (mListY * ratio);
            recalculateOffset();
            mOldBackgroundHeight = -1;
        }
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        if (mAdapter == null || mAdapter.isEmpty()) {
            return;
        }

        if (getChildCount() == 0) {
            addAndMeasureItemViews();
        }

        positionItemViews();
        invalidate();
    }

    private void addAndMeasureItemViews() {
        View child;
        for (int i = 0; i < mAdapter.getCount(); i++) {
            // TODO Cache non visible views and use them
            child = mAdapter.getView(i, null, this);
            ViewGroup.LayoutParams params = child.getLayoutParams();
            if (params == null) {
                params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
            }
            addViewInLayout(child, i, params, true);

            GeneralScheduleItem item = mAdapter.getItem(i);
            child.measure(MeasureSpec.makeMeasureSpec(mItemWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
                    calculateDistance(item.getStartTime(), item.getEndTime()), MeasureSpec.EXACTLY));
        }
        setSelection();
    }

    private int calculateDistance(long start, long end) {
        float hours = (float) (end - start) / MILLIS_IN_HOUR;
        return (int) (hours * mHourHeight);
    }

    private void positionItemViews() {
        if (mAdapter == null) {
            return;
        }

        View child;
        final int right = mViewWidth - getPaddingRight() - mItemPaddingRight;
        for (int i = 0; i < mAdapter.getCount(); i++) {
            child = getChildAt(i);
            int width = child.getMeasuredWidth();
            int height = child.getMeasuredHeight();

            GeneralScheduleItem item = mAdapter.getItem(i);

            int top = mListY + mInternalPaddingTop + getPaddingTop() + mTimeMarkHeight / 2
                    + calculateDistance(mStartTime, getTimeInMillis(item.getStartTime()));

            child.layout(right - width, top, right, top + height);
        }
    }

    private long getTimeInMillis(long time) {
        Calendar c = Calendar.getInstance(getTimeZone());
        c.setTimeInMillis(time);
        return (c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)) * 60 * 1000;
    }

    private void releaseEdgeEffects() {
        mTopEdgeEffectActive = false;
        mBottomEdgeEffectActive = false;
        mTopEdgeEffect.onRelease();
        mBottomEdgeEffect.onRelease();
    }

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        View child;
        for (int i = 0; i < getChildCount(); i++) {
            child = getChildAt(i);
            child.getHitRect(mClickedViewBounds);
            if (mClickedViewBounds.contains((int) e.getX(), (int) e.getY())) {
                if (DEBUG) {
                    Log.d(TAG, "dispatchTouchEvent() to child " + i);
                }
                // FIXME Add fade animation
                child.dispatchTouchEvent(e);
            }
        }
        return mGestureDetector.onTouchEvent(e);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        return true;
    }

    @Override
    public void computeScroll() {
        super.computeScroll();

        boolean needsInvalidate = false;
        if (mOverScroller.computeScrollOffset()) {
            mListY = mOverScroller.getCurrY();

            positionItemViews();

            if (mOverScroller.isOverScrolled()) {
                if (mScrollDirection > 0) {
                    mListY = 0;
                } else if (mScrollDirection < 0) {
                    mListY = -mDeltaHeight;
                }

                if (mListY == 0 && !mTopEdgeEffectActive) {
                    mTopEdgeEffect.onAbsorb(getCurrentVelocity());
                    mTopEdgeEffectActive = true;
                    needsInvalidate = true;
                } else if (mListY == -mDeltaHeight && !mBottomEdgeEffectActive) {
                    mBottomEdgeEffect.onAbsorb(getCurrentVelocity());
                    mBottomEdgeEffectActive = true;
                    needsInvalidate = true;
                }
            }
        }

        if (!mOverScroller.isFinished()) {
            needsInvalidate = true;
        }

        if (needsInvalidate) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    @Override
    protected int computeVerticalScrollExtent() {
        return mViewHeight;
    }

    @Override
    protected int computeVerticalScrollOffset() {
        return -mListY;
    }

    @Override
    protected int computeVerticalScrollRange() {
        return mBackgroundHeight;
    }

    private int calculateBackgroundHeight() {
        return mInternalPaddingTop + getPaddingTop() + mTimeMarks.size() * (mTimeMarkHeight + mTimeMarksDistance)
                + mInternalPaddingBottom + getPaddingBottom();
    }

    private void changeClipRect(Canvas canvas) {
        canvas.getClipBounds(mClipRect);
        mClipRect.left += getPaddingLeft();
        mClipRect.top += getPaddingTop();
        mClipRect.right -= getPaddingRight();
        mClipRect.bottom -= getPaddingBottom();
        canvas.clipRect(mClipRect);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        changeClipRect(canvas);

        canvas.save();
        canvas.translate(getPaddingLeft(), mListY + mInternalPaddingTop + getPaddingTop());
        ViewHolder holder = (ViewHolder) mTimeMark.getTag();
        for (int i = 0; i < mTimeMarks.size(); i++) {
            holder.time.setText(mTimeMarks.get(i));
            mTimeMark.layout(0, 0, mTimeMark.getMeasuredWidth(), mTimeMark.getMeasuredHeight());
            mTimeMark.draw(canvas);

            if (i != mTimeMarks.size() - 1) {
                canvas.translate(0, mTimeMarkHeight + mTimeMarksDistance);
            }
        }
        canvas.restore();

        drawEdgeEffects(canvas);
    }

    private void drawEdgeEffects(Canvas canvas) {
        boolean needsInvalidate = false;

        final int overScrollMode = ViewCompat.getOverScrollMode(this);
        if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS
                || overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS) {
            if (!mTopEdgeEffect.isFinished()) {
                int saveCount = canvas.save();
                int width = mViewWidth - getPaddingLeft() - getPaddingRight();
                int height = mViewHeight - getPaddingTop() - getPaddingBottom();

                canvas.translate(0, getPaddingTop());

                mTopEdgeEffect.setSize(width, height);
                needsInvalidate |= mTopEdgeEffect.draw(canvas);
                canvas.restoreToCount(saveCount);
            }
            if (!mBottomEdgeEffect.isFinished()) {
                int saveCount = canvas.save();
                int width = mViewWidth - getPaddingLeft() - getPaddingRight();
                int height = mViewHeight - getPaddingTop() - getPaddingBottom();

                canvas.translate(mViewWidth, mViewHeight - getPaddingBottom());
                canvas.rotate(180);

                mBottomEdgeEffect.setSize(width, height);
                needsInvalidate |= mBottomEdgeEffect.draw(canvas);
                canvas.restoreToCount(saveCount);
            }
        } else {
            mTopEdgeEffect.finish();
            mBottomEdgeEffect.finish();
        }

        if (needsInvalidate) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    public static class ScheduleState extends BaseSavedState {
        int listY;
        int backgroundHeight;
        boolean isActionMode;
        Set<Long> selectedIds;

        public ScheduleState(Parcelable superState) {
            super(superState);
            selectedIds = new HashSet<Long>();
        }

        private ScheduleState(Parcel in) {
            super(in);
            listY = in.readInt();
            backgroundHeight = in.readInt();
            isActionMode = in.readInt() == 1;
            Long[] ids = (Long[]) in.readArray(Long.class.getClassLoader());
            selectedIds = new HashSet<Long>(Arrays.asList(ids));
        }

        public static final Parcelable.Creator<ScheduleState> CREATOR = new Parcelable.Creator<ScheduleState>() {
            @Override
            public ScheduleState createFromParcel(Parcel in) {
                return new ScheduleState(in);
            }

            @Override
            public ScheduleState[] newArray(int size) {
                return new ScheduleState[0];
            }
        };

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(listY);
            out.writeInt(backgroundHeight);
            out.writeInt(isActionMode ? 1 : 0);
            out.writeArray(selectedIds.toArray(new Long[selectedIds.size()]));
        }
    }
}