BaseCardView.java

/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * 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 androidx.leanback.widget;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewDebug;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.Animation;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Transformation;
import android.widget.FrameLayout;

import androidx.annotation.VisibleForTesting;
import androidx.leanback.R;

import java.util.ArrayList;

/**
 * A card style layout that responds to certain state changes. It arranges its
 * children in a vertical column, with different regions becoming visible at
 * different times.
 *
 * <p>
 * A BaseCardView will draw its children based on its type, the region
 * visibilities of the child types, and the state of the widget. A child may be
 * marked as belonging to one of three regions: main, info, or extra. The main
 * region is always visible, while the info and extra regions can be set to
 * display based on the activated or selected state of the View. The card states
 * are set by calling {@link #setActivated(boolean) setActivated} and
 * {@link #setSelected(boolean) setSelected}.
 * <p>
 * See {@link BaseCardView.LayoutParams} for layout attributes.
 * </p>
 */
public class BaseCardView extends FrameLayout {
    private static final String TAG = "BaseCardView";
    private static final boolean DEBUG = false;

    /**
     * A simple card type with a single layout area. This card type does not
     * change its layout or size as it transitions between
     * Activated/Not-Activated or Selected/Unselected states.
     *
     * @see #getCardType()
     */
    public static final int CARD_TYPE_MAIN_ONLY = 0;

    /**
     * A Card type with 2 layout areas: A main area which is always visible, and
     * an info area that fades in over the main area when it is visible.
     * The card height will not change.
     *
     * @see #getCardType()
     */
    public static final int CARD_TYPE_INFO_OVER = 1;

    /**
     * A Card type with 2 layout areas: A main area which is always visible, and
     * an info area that appears below the main area. When the info area is visible
     * the total card height will change.
     *
     * @see #getCardType()
     */
    public static final int CARD_TYPE_INFO_UNDER = 2;

    /**
     * A Card type with 3 layout areas: A main area which is always visible; an
     * info area which will appear below the main area, and an extra area that
     * only appears after a short delay. The info area appears below the main
     * area, causing the total card height to change. The extra area animates in
     * at the bottom of the card, shifting up the info view without affecting
     * the card height.
     *
     * @see #getCardType()
     */
    public static final int CARD_TYPE_INFO_UNDER_WITH_EXTRA = 3;

    /**
     * Indicates that a card region is always visible.
     */
    public static final int CARD_REGION_VISIBLE_ALWAYS = 0;

    /**
     * Indicates that a card region is visible when the card is activated.
     */
    public static final int CARD_REGION_VISIBLE_ACTIVATED = 1;

    /**
     * Indicates that a card region is visible when the card is selected.
     */
    public static final int CARD_REGION_VISIBLE_SELECTED = 2;

    private static final int CARD_TYPE_INVALID = 4;

    private int mCardType;
    private int mInfoVisibility;
    private int mExtraVisibility;

    private ArrayList<View> mMainViewList;
    ArrayList<View> mInfoViewList;
    ArrayList<View> mExtraViewList;

    private int mMeasuredWidth;
    private int mMeasuredHeight;
    private boolean mDelaySelectedAnim;
    private int mSelectedAnimationDelay;
    private final int mActivatedAnimDuration;
    private final int mSelectedAnimDuration;

    /**
     * Distance of top of info view to bottom of MainView, it will shift up when extra view appears.
     */
    float mInfoOffset;
    float mInfoVisFraction;
    float mInfoAlpha;
    private Animation mAnim;

    private final static int[] LB_PRESSED_STATE_SET = new int[]{
        android.R.attr.state_pressed};

    private final Runnable mAnimationTrigger = new Runnable() {
        @Override
        public void run() {
            animateInfoOffset(true);
        }
    };

    public BaseCardView(Context context) {
        this(context, null);
    }

    public BaseCardView(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.baseCardViewStyle);
    }

    @SuppressLint("CustomViewStyleable")
    public BaseCardView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.lbBaseCardView,
                defStyleAttr, 0);

        try {
            mCardType = a.getInteger(R.styleable.lbBaseCardView_cardType, CARD_TYPE_MAIN_ONLY);
            Drawable cardForeground = a.getDrawable(R.styleable.lbBaseCardView_cardForeground);
            if (cardForeground != null) {
                setForeground(cardForeground);
            }
            Drawable cardBackground = a.getDrawable(R.styleable.lbBaseCardView_cardBackground);
            if (cardBackground != null) {
                setBackground(cardBackground);
            }
            mInfoVisibility = a.getInteger(R.styleable.lbBaseCardView_infoVisibility,
                    CARD_REGION_VISIBLE_ACTIVATED);
            mExtraVisibility = a.getInteger(R.styleable.lbBaseCardView_extraVisibility,
                    CARD_REGION_VISIBLE_SELECTED);
            // Extra region should never show before info region.
            if (mExtraVisibility < mInfoVisibility) {
                mExtraVisibility = mInfoVisibility;
            }

            mSelectedAnimationDelay = a.getInteger(
                    R.styleable.lbBaseCardView_selectedAnimationDelay,
                    getResources().getInteger(R.integer.lb_card_selected_animation_delay));

            mSelectedAnimDuration = a.getInteger(
                    R.styleable.lbBaseCardView_selectedAnimationDuration,
                    getResources().getInteger(R.integer.lb_card_selected_animation_duration));

            mActivatedAnimDuration =
                    a.getInteger(R.styleable.lbBaseCardView_activatedAnimationDuration,
                    getResources().getInteger(R.integer.lb_card_activated_animation_duration));
        } finally {
            a.recycle();
        }

        mDelaySelectedAnim = true;

        mMainViewList = new ArrayList<View>();
        mInfoViewList = new ArrayList<View>();
        mExtraViewList = new ArrayList<View>();

        mInfoOffset = 0.0f;
        mInfoVisFraction = getFinalInfoVisFraction();
        mInfoAlpha = getFinalInfoAlpha();
    }

    /**
     * Sets a flag indicating if the Selected animation (if the selected card
     * type implements one) should run immediately after the card is selected,
     * or if it should be delayed. The default behavior is to delay this
     * animation. This is a one-shot override. If set to false, after the card
     * is selected and the selected animation is triggered, this flag is
     * automatically reset to true. This is useful when you want to change the
     * default behavior, and have the selected animation run immediately. One
     * such case could be when focus moves from one row to the other, when
     * instead of delaying the selected animation until the user pauses on a
     * card, it may be desirable to trigger the animation for that card
     * immediately.
     *
     * @param delay True (default) if the selected animation should be delayed
     *            after the card is selected, or false if the animation should
     *            run immediately the next time the card is Selected.
     */
    public void setSelectedAnimationDelayed(boolean delay) {
        mDelaySelectedAnim = delay;
    }

    /**
     * Returns a boolean indicating if the selected animation will run
     * immediately or be delayed the next time the card is Selected.
     *
     * @return true if this card is set to delay the selected animation the next
     *         time it is selected, or false if the selected animation will run
     *         immediately the next time the card is selected.
     */
    public boolean isSelectedAnimationDelayed() {
        return mDelaySelectedAnim;
    }

    /**
     * Sets the type of this Card.
     *
     * @param type The desired card type.
     */
    public void setCardType(int type) {
        if (mCardType != type) {
            if (type >= CARD_TYPE_MAIN_ONLY && type < CARD_TYPE_INVALID) {
                // Valid card type
                mCardType = type;
            } else {
                Log.e(TAG, "Invalid card type specified: " + type
                        + ". Defaulting to type CARD_TYPE_MAIN_ONLY.");
                mCardType = CARD_TYPE_MAIN_ONLY;
            }
            requestLayout();
        }
    }

    /**
     * Returns the type of this Card.
     *
     * @return The type of this card.
     */
    public int getCardType() {
        return mCardType;
    }

    /**
     * Sets the visibility of the info region of the card.
     *
     * @param visibility The region visibility to use for the info region. Must
     *     be one of {@link #CARD_REGION_VISIBLE_ALWAYS},
     *     {@link #CARD_REGION_VISIBLE_SELECTED}, or
     *     {@link #CARD_REGION_VISIBLE_ACTIVATED}.
     */
    public void setInfoVisibility(int visibility) {
        if (mInfoVisibility != visibility) {
            cancelAnimations();
            mInfoVisibility = visibility;
            mInfoVisFraction = getFinalInfoVisFraction();
            requestLayout();
            float newInfoAlpha = getFinalInfoAlpha();
            if (newInfoAlpha != mInfoAlpha) {
                mInfoAlpha = newInfoAlpha;
                for (int i = 0; i < mInfoViewList.size(); i++) {
                    mInfoViewList.get(i).setAlpha(mInfoAlpha);
                }
            }
        }
    }

    final float getFinalInfoVisFraction() {
        return mCardType == CARD_TYPE_INFO_UNDER && mInfoVisibility == CARD_REGION_VISIBLE_SELECTED
                && !isSelected() ? 0.0f : 1.0f;
    }

    final float getFinalInfoAlpha() {
        return mCardType == CARD_TYPE_INFO_OVER && mInfoVisibility == CARD_REGION_VISIBLE_SELECTED
                && !isSelected() ? 0.0f : 1.0f;
    }

    /**
     * Returns the visibility of the info region of the card.
     */
    public int getInfoVisibility() {
        return mInfoVisibility;
    }

    /**
     * Sets the visibility of the extra region of the card.
     *
     * @param visibility The region visibility to use for the extra region. Must
     *     be one of {@link #CARD_REGION_VISIBLE_ALWAYS},
     *     {@link #CARD_REGION_VISIBLE_SELECTED}, or
     *     {@link #CARD_REGION_VISIBLE_ACTIVATED}.
     * @deprecated Extra view's visibility is controlled by {@link #setInfoVisibility(int)}
     */
    @Deprecated
    public void setExtraVisibility(int visibility) {
        if (mExtraVisibility != visibility) {
            mExtraVisibility = visibility;
        }
    }

    /**
     * Returns the visibility of the extra region of the card.
     * @deprecated Extra view's visibility is controlled by {@link #getInfoVisibility()}
     */
    @Deprecated
    public int getExtraVisibility() {
        return mExtraVisibility;
    }

    /**
     * Sets the Activated state of this Card. This can trigger changes in the
     * card layout, resulting in views to become visible or hidden. A card is
     * normally set to Activated state when its parent container (like a Row)
     * receives focus, and then activates all of its children.
     *
     * @param activated True if the card is ACTIVE, or false if INACTIVE.
     * @see #isActivated()
     */
    @Override
    public void setActivated(boolean activated) {
        if (activated != isActivated()) {
            super.setActivated(activated);
            applyActiveState();
        }
    }

    /**
     * Sets the Selected state of this Card. This can trigger changes in the
     * card layout, resulting in views to become visible or hidden. A card is
     * normally set to Selected state when it receives input focus.
     *
     * @param selected True if the card is Selected, or false otherwise.
     * @see #isSelected()
     */
    @Override
    public void setSelected(boolean selected) {
        if (selected != isSelected()) {
            super.setSelected(selected);
            applySelectedState(isSelected());
        }
    }

    @Override
    public boolean shouldDelayChildPressedState() {
        return false;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        mMeasuredWidth = 0;
        mMeasuredHeight = 0;
        int state = 0;
        int mainHeight = 0;
        int infoHeight = 0;
        int extraHeight = 0;

        findChildrenViews();

        final int unspecifiedSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        // MAIN is always present
        for (int i = 0; i < mMainViewList.size(); i++) {
            View mainView = mMainViewList.get(i);
            if (mainView.getVisibility() != View.GONE) {
                measureChild(mainView, unspecifiedSpec, unspecifiedSpec);
                mMeasuredWidth = Math.max(mMeasuredWidth, mainView.getMeasuredWidth());
                mainHeight += mainView.getMeasuredHeight();
                state = View.combineMeasuredStates(state, mainView.getMeasuredState());
            }
        }
        setPivotX(mMeasuredWidth / 2);
        setPivotY(mainHeight / 2);


        // The MAIN area determines the card width
        int cardWidthMeasureSpec = MeasureSpec.makeMeasureSpec(mMeasuredWidth, MeasureSpec.EXACTLY);

        if (hasInfoRegion()) {
            for (int i = 0; i < mInfoViewList.size(); i++) {
                View infoView = mInfoViewList.get(i);
                if (infoView.getVisibility() != View.GONE) {
                    measureChild(infoView, cardWidthMeasureSpec, unspecifiedSpec);
                    if (mCardType != CARD_TYPE_INFO_OVER) {
                        infoHeight += infoView.getMeasuredHeight();
                    }
                    state = View.combineMeasuredStates(state, infoView.getMeasuredState());
                }
            }

            if (hasExtraRegion()) {
                for (int i = 0; i < mExtraViewList.size(); i++) {
                    View extraView = mExtraViewList.get(i);
                    if (extraView.getVisibility() != View.GONE) {
                        measureChild(extraView, cardWidthMeasureSpec, unspecifiedSpec);
                        extraHeight += extraView.getMeasuredHeight();
                        state = View.combineMeasuredStates(state, extraView.getMeasuredState());
                    }
                }
            }
        }

        boolean infoAnimating = hasInfoRegion() && mInfoVisibility == CARD_REGION_VISIBLE_SELECTED;
        mMeasuredHeight = (int) (mainHeight
                + (infoAnimating ? (infoHeight * mInfoVisFraction) : infoHeight)
                + extraHeight - (infoAnimating ? 0 : mInfoOffset));

        // Report our final dimensions.
        setMeasuredDimension(View.resolveSizeAndState(mMeasuredWidth + getPaddingLeft()
                + getPaddingRight(), widthMeasureSpec, state),
                View.resolveSizeAndState(mMeasuredHeight + getPaddingTop() + getPaddingBottom(),
                        heightMeasureSpec, state << View.MEASURED_HEIGHT_STATE_SHIFT));
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        float currBottom = getPaddingTop();

        // MAIN is always present
        for (int i = 0; i < mMainViewList.size(); i++) {
            View mainView = mMainViewList.get(i);
            if (mainView.getVisibility() != View.GONE) {
                mainView.layout(getPaddingLeft(),
                        (int) currBottom,
                                mMeasuredWidth + getPaddingLeft(),
                        (int) (currBottom + mainView.getMeasuredHeight()));
                currBottom += mainView.getMeasuredHeight();
            }
        }

        if (hasInfoRegion()) {
            float infoHeight = 0f;
            for (int i = 0; i < mInfoViewList.size(); i++) {
                infoHeight += mInfoViewList.get(i).getMeasuredHeight();
            }

            if (mCardType == CARD_TYPE_INFO_OVER) {
                // retract currBottom to overlap the info views on top of main
                currBottom -= infoHeight;
                if (currBottom < 0) {
                    currBottom = 0;
                }
            } else if (mCardType == CARD_TYPE_INFO_UNDER) {
                if (mInfoVisibility == CARD_REGION_VISIBLE_SELECTED) {
                    infoHeight = infoHeight * mInfoVisFraction;
                }
            } else {
                currBottom -= mInfoOffset;
            }

            for (int i = 0; i < mInfoViewList.size(); i++) {
                View infoView = mInfoViewList.get(i);
                if (infoView.getVisibility() != View.GONE) {
                    int viewHeight = infoView.getMeasuredHeight();
                    if (viewHeight > infoHeight) {
                        viewHeight = (int) infoHeight;
                    }
                    infoView.layout(getPaddingLeft(),
                            (int) currBottom,
                                    mMeasuredWidth + getPaddingLeft(),
                            (int) (currBottom + viewHeight));
                    currBottom += viewHeight;
                    infoHeight -= viewHeight;
                    if (infoHeight <= 0) {
                        break;
                    }
                }
            }

            if (hasExtraRegion()) {
                for (int i = 0; i < mExtraViewList.size(); i++) {
                    View extraView = mExtraViewList.get(i);
                    if (extraView.getVisibility() != View.GONE) {
                        extraView.layout(getPaddingLeft(),
                                (int) currBottom,
                                        mMeasuredWidth + getPaddingLeft(),
                                (int) (currBottom + extraView.getMeasuredHeight()));
                        currBottom += extraView.getMeasuredHeight();
                    }
                }
            }
        }
        // Force update drawable bounds.
        onSizeChanged(0, 0, right - left, bottom - top);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        removeCallbacks(mAnimationTrigger);
        cancelAnimations();
    }

    private boolean hasInfoRegion() {
        return mCardType != CARD_TYPE_MAIN_ONLY;
    }

    private boolean hasExtraRegion() {
        return mCardType == CARD_TYPE_INFO_UNDER_WITH_EXTRA;
    }

    /**
     * Returns target visibility of info region.
     */
    private boolean isRegionVisible(int regionVisibility) {
        switch (regionVisibility) {
            case CARD_REGION_VISIBLE_ALWAYS:
                return true;
            case CARD_REGION_VISIBLE_ACTIVATED:
                return isActivated();
            case CARD_REGION_VISIBLE_SELECTED:
                return isSelected();
            default:
                if (DEBUG) Log.e(TAG, "invalid region visibility state: " + regionVisibility);
                return false;
        }
    }

    /**
     * Unlike isRegionVisible(), this method returns true when it is fading out when unselected.
     */
    private boolean isCurrentRegionVisible(int regionVisibility) {
        switch (regionVisibility) {
            case CARD_REGION_VISIBLE_ALWAYS:
                return true;
            case CARD_REGION_VISIBLE_ACTIVATED:
                return isActivated();
            case CARD_REGION_VISIBLE_SELECTED:
                if (mCardType == CARD_TYPE_INFO_UNDER) {
                    return mInfoVisFraction > 0f;
                } else {
                    return isSelected();
                }
            default:
                if (DEBUG) Log.e(TAG, "invalid region visibility state: " + regionVisibility);
                return false;
        }
    }

    private void findChildrenViews() {
        mMainViewList.clear();
        mInfoViewList.clear();
        mExtraViewList.clear();

        final int count = getChildCount();

        boolean infoVisible = hasInfoRegion() && isCurrentRegionVisible(mInfoVisibility);
        boolean extraVisible = hasExtraRegion() && mInfoOffset > 0f;

        for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);

            if (child == null) {
                continue;
            }

            BaseCardView.LayoutParams lp = (BaseCardView.LayoutParams) child
                    .getLayoutParams();
            if (lp.viewType == LayoutParams.VIEW_TYPE_INFO) {
                child.setAlpha(mInfoAlpha);
                mInfoViewList.add(child);
                child.setVisibility(infoVisible ? View.VISIBLE : View.GONE);
            } else if (lp.viewType == LayoutParams.VIEW_TYPE_EXTRA) {
                mExtraViewList.add(child);
                child.setVisibility(extraVisible ? View.VISIBLE : View.GONE);
            } else {
                // Default to MAIN
                mMainViewList.add(child);
                child.setVisibility(View.VISIBLE);
            }
        }

    }

    @Override
    protected int[] onCreateDrawableState(int extraSpace) {
        // filter out focus states,  since leanback does not fade foreground on focus.
        final int[] s = super.onCreateDrawableState(extraSpace);
        final int N = s.length;
        boolean pressed = false;
        boolean enabled = false;
        for (int i = 0; i < N; i++) {
            if (s[i] == android.R.attr.state_pressed) {
                pressed = true;
            }
            if (s[i] == android.R.attr.state_enabled) {
                enabled = true;
            }
        }
        if (pressed && enabled) {
            return View.PRESSED_ENABLED_STATE_SET;
        } else if (pressed) {
            return LB_PRESSED_STATE_SET;
        } else if (enabled) {
            return View.ENABLED_STATE_SET;
        } else {
            return View.EMPTY_STATE_SET;
        }
    }

    private void applyActiveState() {
        if (hasInfoRegion() && mInfoVisibility == CARD_REGION_VISIBLE_ACTIVATED) {
            setInfoViewVisibility(isRegionVisible(mInfoVisibility));
        }
    }

    private void setInfoViewVisibility(boolean visible) {
        if (mCardType == CARD_TYPE_INFO_UNDER_WITH_EXTRA) {
            // Active state changes for card type
            // CARD_TYPE_INFO_UNDER_WITH_EXTRA
            if (visible) {
                for (int i = 0; i < mInfoViewList.size(); i++) {
                    mInfoViewList.get(i).setVisibility(View.VISIBLE);
                }
            } else {
                for (int i = 0; i < mInfoViewList.size(); i++) {
                    mInfoViewList.get(i).setVisibility(View.GONE);
                }
                for (int i = 0; i < mExtraViewList.size(); i++) {
                    mExtraViewList.get(i).setVisibility(View.GONE);
                }
                mInfoOffset = 0.0f;
            }
        } else if (mCardType == CARD_TYPE_INFO_UNDER) {
            // Active state changes for card type CARD_TYPE_INFO_UNDER
            if (mInfoVisibility == CARD_REGION_VISIBLE_SELECTED) {
                animateInfoHeight(visible);
            } else {
                for (int i = 0; i < mInfoViewList.size(); i++) {
                    mInfoViewList.get(i).setVisibility(visible ? View.VISIBLE : View.GONE);
                }
            }
        } else if (mCardType == CARD_TYPE_INFO_OVER) {
            // Active state changes for card type CARD_TYPE_INFO_OVER
            animateInfoAlpha(visible);
        }
    }

    private void applySelectedState(boolean focused) {
        removeCallbacks(mAnimationTrigger);

        if (mCardType == CARD_TYPE_INFO_UNDER_WITH_EXTRA) {
            // Focus changes for card type CARD_TYPE_INFO_UNDER_WITH_EXTRA
            if (focused) {
                if (!mDelaySelectedAnim) {
                    post(mAnimationTrigger);
                    mDelaySelectedAnim = true;
                } else {
                    postDelayed(mAnimationTrigger, mSelectedAnimationDelay);
                }
            } else {
                animateInfoOffset(false);
            }
        } else if (mInfoVisibility == CARD_REGION_VISIBLE_SELECTED) {
            setInfoViewVisibility(focused);
        }
    }

    void cancelAnimations() {
        if (mAnim != null) {
            mAnim.cancel();
            mAnim = null;
            // force-clear the animation, as Animation#cancel() doesn't work prior to N,
            // and will instead cause the animation to infinitely loop
            clearAnimation();
        }
    }

    // This animation changes the Y offset of the info and extra views,
    // so that they animate UP to make the extra info area visible when a
    // card is selected.
    void animateInfoOffset(boolean shown) {
        cancelAnimations();

        int extraHeight = 0;
        if (shown) {
            int widthSpec = MeasureSpec.makeMeasureSpec(mMeasuredWidth, MeasureSpec.EXACTLY);
            int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);

            for (int i = 0; i < mExtraViewList.size(); i++) {
                View extraView = mExtraViewList.get(i);
                extraView.setVisibility(View.VISIBLE);
                extraView.measure(widthSpec, heightSpec);
                extraHeight = Math.max(extraHeight, extraView.getMeasuredHeight());
            }
        }

        mAnim = new InfoOffsetAnimation(mInfoOffset, shown ? extraHeight : 0);
        mAnim.setDuration(mSelectedAnimDuration);
        mAnim.setInterpolator(new AccelerateDecelerateInterpolator());
        mAnim.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {
            }

            @Override
            public void onAnimationEnd(Animation animation) {
                if (mInfoOffset == 0f) {
                    for (int i = 0; i < mExtraViewList.size(); i++) {
                        mExtraViewList.get(i).setVisibility(View.GONE);
                    }
                }
            }

                @Override
            public void onAnimationRepeat(Animation animation) {
            }

        });
        startAnimation(mAnim);
    }

    // This animation changes the visible height of the info views,
    // so that they animate in and out of view.
    private void animateInfoHeight(boolean shown) {
        cancelAnimations();

        if (shown) {
            for (int i = 0; i < mInfoViewList.size(); i++) {
                View extraView = mInfoViewList.get(i);
                extraView.setVisibility(View.VISIBLE);
            }
        }

        float targetFraction = shown ? 1.0f : 0f;
        if (mInfoVisFraction == targetFraction) {
            return;
        }
        mAnim = new InfoHeightAnimation(mInfoVisFraction, targetFraction);
        mAnim.setDuration(mSelectedAnimDuration);
        mAnim.setInterpolator(new AccelerateDecelerateInterpolator());
        mAnim.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {
            }

            @Override
            public void onAnimationEnd(Animation animation) {
                if (mInfoVisFraction == 0f) {
                    for (int i = 0; i < mInfoViewList.size(); i++) {
                        mInfoViewList.get(i).setVisibility(View.GONE);
                    }
                }
            }

            @Override
            public void onAnimationRepeat(Animation animation) {
            }

        });
        startAnimation(mAnim);
    }

    // This animation changes the alpha of the info views, so they animate in
    // and out. It's meant to be used when the info views are overlaid on top of
    // the main view area. It gets triggered by a change in the Active state of
    // the card.
    private void animateInfoAlpha(boolean shown) {
        cancelAnimations();

        if (shown) {
            for (int i = 0; i < mInfoViewList.size(); i++) {
                mInfoViewList.get(i).setVisibility(View.VISIBLE);
            }
        }
        float targetAlpha = shown ? 1.0f : 0.0f;
        if (targetAlpha == mInfoAlpha) {
            return;
        }

        mAnim = new InfoAlphaAnimation(mInfoAlpha, shown ? 1.0f : 0.0f);
        mAnim.setDuration(mActivatedAnimDuration);
        mAnim.setInterpolator(new DecelerateInterpolator());
        mAnim.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {
            }

            @Override
            public void onAnimationEnd(Animation animation) {
                if (mInfoAlpha == 0.0) {
                    for (int i = 0; i < mInfoViewList.size(); i++) {
                        mInfoViewList.get(i).setVisibility(View.GONE);
                    }
                }
            }

            @Override
            public void onAnimationRepeat(Animation animation) {
            }

        });
        startAnimation(mAnim);
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new BaseCardView.LayoutParams(getContext(), attrs);
    }

    @Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new BaseCardView.LayoutParams(
                LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    @Override
    protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
        if (lp instanceof LayoutParams) {
            return new LayoutParams((LayoutParams) lp);
        } else {
            return new LayoutParams(lp);
        }
    }

    @Override
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return p instanceof BaseCardView.LayoutParams;
    }

    /**
     * Per-child layout information associated with BaseCardView.
     */
    public static class LayoutParams extends FrameLayout.LayoutParams {
        public static final int VIEW_TYPE_MAIN = 0;
        public static final int VIEW_TYPE_INFO = 1;
        public static final int VIEW_TYPE_EXTRA = 2;

        /**
         * Card component type for the view associated with these LayoutParams.
         */
        @ViewDebug.ExportedProperty(category = "layout", mapping = {
                @ViewDebug.IntToString(from = VIEW_TYPE_MAIN, to = "MAIN"),
                @ViewDebug.IntToString(from = VIEW_TYPE_INFO, to = "INFO"),
                @ViewDebug.IntToString(from = VIEW_TYPE_EXTRA, to = "EXTRA")
        })
        public int viewType = VIEW_TYPE_MAIN;

        /**
         * {@inheritDoc}
         */
        @SuppressLint("CustomViewStyleable")
        public LayoutParams(Context c, AttributeSet attrs) {
            super(c, attrs);
            TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.lbBaseCardView_Layout);

            viewType = a.getInt(
                    R.styleable.lbBaseCardView_Layout_layout_viewType, VIEW_TYPE_MAIN);

            a.recycle();
        }

        /**
         * {@inheritDoc}
         */
        public LayoutParams(int width, int height) {
            super(width, height);
        }

        /**
         * {@inheritDoc}
         */
        public LayoutParams(ViewGroup.LayoutParams p) {
            super(p);
        }

        /**
         * Copy constructor. Clones the width, height, and View Type of the
         * source.
         *
         * @param source The layout params to copy from.
         */
        public LayoutParams(LayoutParams source) {
            super((ViewGroup.MarginLayoutParams) source);

            this.viewType = source.viewType;
        }
    }

    class AnimationBase extends Animation {

        @VisibleForTesting
        final void mockStart() {
            getTransformation(0, null);
        }

        @VisibleForTesting
        final void mockEnd() {
            applyTransformation(1f, null);
            cancelAnimations();
        }
    }

    // Helper animation class used in the animation of the info and extra
    // fields vertically within the card
    final class InfoOffsetAnimation extends AnimationBase {
        private float mStartValue;
        private float mDelta;

        public InfoOffsetAnimation(float start, float end) {
            mStartValue = start;
            mDelta = end - start;
        }

        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            mInfoOffset = mStartValue + (interpolatedTime * mDelta);
            requestLayout();
        }
    }

    // Helper animation class used in the animation of the visible height
    // for the info fields.
    final class InfoHeightAnimation extends AnimationBase {
        private float mStartValue;
        private float mDelta;

        public InfoHeightAnimation(float start, float end) {
            mStartValue = start;
            mDelta = end - start;
        }

        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            mInfoVisFraction = mStartValue + (interpolatedTime * mDelta);
            requestLayout();
        }
    }

    // Helper animation class used to animate the alpha for the info views
    // when they are fading in or out of view.
    final class InfoAlphaAnimation extends AnimationBase {
        private float mStartValue;
        private float mDelta;

        public InfoAlphaAnimation(float start, float end) {
            mStartValue = start;
            mDelta = end - start;
        }

        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            mInfoAlpha = mStartValue + (interpolatedTime * mDelta);
            for (int i = 0; i < mInfoViewList.size(); i++) {
                mInfoViewList.get(i).setAlpha(mInfoAlpha);
            }
        }
    }

    @Override
    public String toString() {
        if (DEBUG) {
            StringBuilder sb = new StringBuilder();
            sb.append(this.getClass().getSimpleName()).append(" : ");
            sb.append("cardType=");
            switch(mCardType) {
                case CARD_TYPE_MAIN_ONLY:
                    sb.append("MAIN_ONLY");
                    break;
                case CARD_TYPE_INFO_OVER:
                    sb.append("INFO_OVER");
                    break;
                case CARD_TYPE_INFO_UNDER:
                    sb.append("INFO_UNDER");
                    break;
                case CARD_TYPE_INFO_UNDER_WITH_EXTRA:
                    sb.append("INFO_UNDER_WITH_EXTRA");
                    break;
                default:
                    sb.append("INVALID");
                    break;
            }
            sb.append(" : ");
            sb.append(mMainViewList.size()).append(" main views, ");
            sb.append(mInfoViewList.size()).append(" info views, ");
            sb.append(mExtraViewList.size()).append(" extra views : ");
            sb.append("infoVisibility=").append(mInfoVisibility).append(" ");
            sb.append("extraVisibility=").append(mExtraVisibility).append(" ");
            sb.append("isActivated=").append(isActivated());
            sb.append(" : ");
            sb.append("isSelected=").append(isSelected());
            return sb.toString();
        } else {
            return super.toString();
        }
    }
}