/*
* Copyright (C) 2015 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 android.support.v17.leanback.widget;
import android.animation.Animator;
import android.animation.AnimatorInflater;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.v17.leanback.R;
import android.support.v17.leanback.widget.VerticalGridView;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.text.TextUtils;
import android.util.Log;
import android.util.TypedValue;
import android.view.animation.DecelerateInterpolator;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewPropertyAnimator;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.TextView;
import java.util.List;
/**
* GuidedActionsStylist is used within a {@link android.support.v17.leanback.app.GuidedStepFragment}
* to supply the right-side panel where users can take actions. It consists of a container for the
* list of actions, and a stationary selector view that indicates visually the location of focus.
* <p>
* Many aspects of the base GuidedActionsStylist can be customized through theming; see the
* theme attributes below. Note that these attributes are not set on individual elements in layout
* XML, but instead would be set in a custom theme. See
* <a href="http://developer.android.com/guide/topics/ui/themes.html">Styles and Themes</a>
* for more information.
* <p>
* If these hooks are insufficient, this class may also be subclassed. Subclasses may wish to
* override the {@link #onProvideLayoutId} method to change the layout used to display the
* list container and selector, or the {@link #onProvideItemLayoutId} method to change the layout
* used to display each action.
* <p>
* Note: If an alternate list layout is provided, the following view IDs must be supplied:
* <ul>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_selector}</li>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_list}</li>
* </ul><p>
* These view IDs must be present in order for the stylist to function. The list ID must correspond
* to a {@link VerticalGridView} or subclass.
* <p>
* If an alternate item layout is provided, the following view IDs should be used to refer to base
* elements:
* <ul>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_item_content}</li>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_item_title}</li>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_item_description}</li>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_item_icon}</li>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_item_checkmark}</li>
* <li>{@link android.support.v17.leanback.R.id#guidedactions_item_chevron}</li>
* </ul><p>
* These view IDs are allowed to be missing, in which case the corresponding views in {@link
* GuidedActionsStylist.ViewHolder} will be null.
*
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionsEntryAnimation
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionsSelectorShowAnimation
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionsSelectorHideAnimation
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionsContainerStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionsSelectorStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionsListStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionItemContainerStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionItemCheckmarkStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionItemIconStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionItemContentStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionItemTitleStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionItemDescriptionStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionItemChevronStyle
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionCheckedAnimation
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionUncheckedAnimation
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionPressedAnimation
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionUnpressedAnimation
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionEnabledChevronAlpha
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionDisabledChevronAlpha
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionContentWidth
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionContentWidthNoIcon
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionTitleMinLines
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionTitleMaxLines
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionDescriptionMinLines
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedActionVerticalPadding
* @see android.support.v17.leanback.app.GuidedStepFragment
* @see GuidedAction
*/
public class GuidedActionsStylist implements FragmentAnimationProvider {
/**
* ViewHolder caches information about the action item layouts' subviews. Subclasses of {@link
* GuidedActionsStylist} may also wish to subclass this in order to add fields.
* @see GuidedAction
*/
public static class ViewHolder {
public final View view;
private View mContentView;
private TextView mTitleView;
private TextView mDescriptionView;
private ImageView mIconView;
private ImageView mCheckmarkView;
private ImageView mChevronView;
/**
* Constructs an ViewHolder and caches the relevant subviews.
*/
public ViewHolder(View v) {
view = v;
mContentView = v.findViewById(R.id.guidedactions_item_content);
mTitleView = (TextView) v.findViewById(R.id.guidedactions_item_title);
mDescriptionView = (TextView) v.findViewById(R.id.guidedactions_item_description);
mIconView = (ImageView) v.findViewById(R.id.guidedactions_item_icon);
mCheckmarkView = (ImageView) v.findViewById(R.id.guidedactions_item_checkmark);
mChevronView = (ImageView) v.findViewById(R.id.guidedactions_item_chevron);
}
/**
* Returns the content view within this view holder's view, where title and description are
* shown.
*/
public View getContentView() {
return mContentView;
}
/**
* Returns the title view within this view holder's view.
*/
public TextView getTitleView() {
return mTitleView;
}
/**
* Returns the description view within this view holder's view.
*/
public TextView getDescriptionView() {
return mDescriptionView;
}
/**
* Returns the icon view within this view holder's view.
*/
public ImageView getIconView() {
return mIconView;
}
/**
* Returns the checkmark view within this view holder's view.
*/
public ImageView getCheckmarkView() {
return mCheckmarkView;
}
/**
* Returns the chevron view within this view holder's view.
*/
public ImageView getChevronView() {
return mChevronView;
}
}
private static String TAG = "GuidedActionsStylist";
protected View mMainView;
protected VerticalGridView mActionsGridView;
protected View mSelectorView;
// Cached values from resources
private float mEnabledChevronAlpha;
private float mDisabledChevronAlpha;
private int mContentWidth;
private int mContentWidthNoIcon;
private int mTitleMinLines;
private int mTitleMaxLines;
private int mDescriptionMinLines;
private int mVerticalPadding;
private int mDisplayHeight;
/**
* Creates a view appropriate for displaying a list of GuidedActions, using the provided
* inflater and container.
* <p>
* <i>Note: Does not actually add the created view to the container; the caller should do
* this.</i>
* @param inflater The layout inflater to be used when constructing the view.
* @param container The view group to be passed in the call to
* <code>LayoutInflater.inflate</code>.
* @return The view to be added to the caller's view hierarchy.
*/
public View onCreateView(LayoutInflater inflater, ViewGroup container) {
mMainView = inflater.inflate(onProvideLayoutId(), container, false);
mSelectorView = mMainView.findViewById(R.id.guidedactions_selector);
if (mMainView instanceof VerticalGridView) {
mActionsGridView = (VerticalGridView) mMainView;
} else {
mActionsGridView = (VerticalGridView) mMainView.findViewById(R.id.guidedactions_list);
if (mActionsGridView == null) {
throw new IllegalStateException("No ListView exists.");
}
mActionsGridView.setWindowAlignmentOffset(0);
mActionsGridView.setWindowAlignmentOffsetPercent(50f);
mActionsGridView.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_NO_EDGE);
if (mSelectorView != null) {
mActionsGridView.setOnScrollListener(new
SelectorAnimator(mSelectorView, mActionsGridView));
}
}
mActionsGridView.requestFocusFromTouch();
if (mSelectorView != null) {
// ALlow focus to move to other views
mActionsGridView.getViewTreeObserver().addOnGlobalFocusChangeListener(
new ViewTreeObserver.OnGlobalFocusChangeListener() {
private boolean mChildFocused;
@Override
public void onGlobalFocusChanged(View oldFocus, View newFocus) {
View focusedChild = mActionsGridView.getFocusedChild();
if (focusedChild == null) {
mSelectorView.setVisibility(View.INVISIBLE);
mChildFocused = false;
} else if (!mChildFocused) {
mChildFocused = true;
mSelectorView.setVisibility(View.VISIBLE);
updateSelectorView(focusedChild);
}
}
});
}
// Cache widths, chevron alpha values, max and min text lines, etc
Context ctx = mMainView.getContext();
TypedValue val = new TypedValue();
mEnabledChevronAlpha = getFloat(ctx, val, R.attr.guidedActionEnabledChevronAlpha);
mDisabledChevronAlpha = getFloat(ctx, val, R.attr.guidedActionDisabledChevronAlpha);
mContentWidth = getDimension(ctx, val, R.attr.guidedActionContentWidth);
mContentWidthNoIcon = getDimension(ctx, val, R.attr.guidedActionContentWidthNoIcon);
mTitleMinLines = getInteger(ctx, val, R.attr.guidedActionTitleMinLines);
mTitleMaxLines = getInteger(ctx, val, R.attr.guidedActionTitleMaxLines);
mDescriptionMinLines = getInteger(ctx, val, R.attr.guidedActionDescriptionMinLines);
mVerticalPadding = getDimension(ctx, val, R.attr.guidedActionVerticalPadding);
mDisplayHeight = ((WindowManager) ctx.getSystemService(Context.WINDOW_SERVICE))
.getDefaultDisplay().getHeight();
return mMainView;
}
/**
* Returns the VerticalGridView that displays the list of GuidedActions.
* @return The VerticalGridView for this presenter.
*/
public VerticalGridView getActionsGridView() {
return mActionsGridView;
}
/**
* Provides the resource ID of the layout defining the host view for the list of guided actions.
* Subclasses may override to provide their own customized layouts. The base implementation
* returns {@link android.support.v17.leanback.R.layout#lb_guidedactions}. If overridden, the
* substituted layout should contain matching IDs for any views that should be managed by the
* base class; this can be achieved by starting with a copy of the base layout file.
* @return The resource ID of the layout to be inflated to define the host view for the list
* of GuidedActions.
*/
public int onProvideLayoutId() {
return R.layout.lb_guidedactions;
}
/**
* Provides the resource ID of the layout defining the view for an individual guided actions.
* Subclasses may override to provide their own customized layouts. The base implementation
* returns {@link android.support.v17.leanback.R.layout#lb_guidedactions_item}. If overridden,
* the substituted layout should contain matching IDs for any views that should be managed by
* the base class; this can be achieved by starting with a copy of the base layout file.
* @return The resource ID of the layout to be inflated to define the view to display an
* individual GuidedAction.
*/
public int onProvideItemLayoutId() {
return R.layout.lb_guidedactions_item;
}
/**
* Constructs a {@link ViewHolder} capable of representing {@link GuidedAction}s. Subclasses
* may choose to return a subclass of ViewHolder.
* <p>
* <i>Note: Should not actually add the created view to the parent; the caller will do
* this.</i>
* @param parent The view group to be used as the parent of the new view.
* @return The view to be added to the caller's view hierarchy.
*/
public ViewHolder onCreateViewHolder(ViewGroup parent) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
View v = inflater.inflate(onProvideItemLayoutId(), parent, false);
return new ViewHolder(v);
}
/**
* Binds a {@link ViewHolder} to a particular {@link GuidedAction}.
* @param vh The view holder to be associated with the given action.
* @param action The guided action to be displayed by the view holder's view.
* @return The view to be added to the caller's view hierarchy.
*/
public void onBindViewHolder(ViewHolder vh, GuidedAction action) {
if (vh.mTitleView != null) {
vh.mTitleView.setText(action.getTitle());
}
if (vh.mDescriptionView != null) {
vh.mDescriptionView.setText(action.getDescription());
vh.mDescriptionView.setVisibility(TextUtils.isEmpty(action.getDescription()) ?
View.GONE : View.VISIBLE);
}
// Clients might want the check mark view to be gone entirely, in which case, ignore it.
if (vh.mCheckmarkView != null && vh.mCheckmarkView.getVisibility() != View.GONE) {
vh.mCheckmarkView.setVisibility(action.isChecked() ? View.VISIBLE : View.INVISIBLE);
}
if (vh.mContentView != null) {
ViewGroup.LayoutParams contentLp = vh.mContentView.getLayoutParams();
if (setIcon(vh.mIconView, action)) {
contentLp.width = mContentWidth;
} else {
contentLp.width = mContentWidthNoIcon;
}
vh.mContentView.setLayoutParams(contentLp);
}
if (vh.mChevronView != null) {
vh.mChevronView.setVisibility(action.hasNext() ? View.VISIBLE : View.INVISIBLE);
vh.mChevronView.setAlpha(action.isEnabled() ? mEnabledChevronAlpha :
mDisabledChevronAlpha);
}
if (action.hasMultilineDescription()) {
if (vh.mTitleView != null) {
vh.mTitleView.setMaxLines(mTitleMaxLines);
if (vh.mDescriptionView != null) {
vh.mDescriptionView.setMaxHeight(getDescriptionMaxHeight(vh.view.getContext(),
vh.mTitleView));
}
}
} else {
if (vh.mTitleView != null) {
vh.mTitleView.setMaxLines(mTitleMinLines);
}
if (vh.mDescriptionView != null) {
vh.mDescriptionView.setMaxLines(mDescriptionMinLines);
}
}
}
/**
* Animates the view holder's view (or subviews thereof) when the action has had its focus
* state changed.
* @param vh The view holder associated with the relevant action.
* @param focused True if the action has become focused, false if it has lost focus.
*/
public void onAnimateItemFocused(ViewHolder vh, boolean focused) {
// No animations for this, currently, because the animation is done on
// mSelectorView
}
/**
* Animates the view holder's view (or subviews thereof) when the action has had its press
* state changed.
* @param vh The view holder associated with the relevant action.
* @param pressed True if the action has been pressed, false if it has been unpressed.
*/
public void onAnimateItemPressed(ViewHolder vh, boolean pressed) {
int attr = pressed ? R.attr.guidedActionPressedAnimation :
R.attr.guidedActionUnpressedAnimation;
createAnimator(vh.view, attr).start();
}
/**
* Animates the view holder's view (or subviews thereof) when the action has had its check
* state changed.
* @param vh The view holder associated with the relevant action.
* @param checked True if the action has become checked, false if it has become unchecked.
*/
public void onAnimateItemChecked(ViewHolder vh, boolean checked) {
final View checkView = vh.mCheckmarkView;
if (checkView != null) {
if (checked) {
checkView.setVisibility(View.VISIBLE);
createAnimator(checkView, R.attr.guidedActionCheckedAnimation).start();
} else {
Animator animator = createAnimator(checkView,
R.attr.guidedActionUncheckedAnimation);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
checkView.setVisibility(View.INVISIBLE);
}
});
animator.start();
}
}
}
/*
* ==========================================
* FragmentAnimationProvider overrides
* ==========================================
*/
/**
* {@inheritDoc}
*/
@Override
public void onActivityEnter(@NonNull List<Animator> animators) {
animators.add(createAnimator(mMainView, R.attr.guidedActionsEntryAnimation));
}
/**
* {@inheritDoc}
*/
@Override
public void onActivityExit(@NonNull List<Animator> animators) {}
/**
* {@inheritDoc}
*/
@Override
public void onFragmentEnter(@NonNull List<Animator> animators) {
animators.add(createAnimator(mActionsGridView, R.attr.guidedStepEntryAnimation));
animators.add(createAnimator(mSelectorView, R.attr.guidedStepEntryAnimation));
}
/**
* {@inheritDoc}
*/
@Override
public void onFragmentExit(@NonNull List<Animator> animators) {
animators.add(createAnimator(mActionsGridView, R.attr.guidedStepExitAnimation));
animators.add(createAnimator(mSelectorView, R.attr.guidedStepExitAnimation));
}
/**
* {@inheritDoc}
*/
@Override
public void onFragmentReenter(@NonNull List<Animator> animators) {
animators.add(createAnimator(mActionsGridView, R.attr.guidedStepReentryAnimation));
animators.add(createAnimator(mSelectorView, R.attr.guidedStepReentryAnimation));
}
/**
* {@inheritDoc}
*/
@Override
public void onFragmentReturn(@NonNull List<Animator> animators) {
animators.add(createAnimator(mActionsGridView, R.attr.guidedStepReturnAnimation));
animators.add(createAnimator(mSelectorView, R.attr.guidedStepReturnAnimation));
}
/*
* ==========================================
* Private methods
* ==========================================
*/
private void updateSelectorView(View focusedChild) {
// Display the selector view.
int height = focusedChild.getHeight();
LayoutParams lp = mSelectorView.getLayoutParams();
lp.height = height;
mSelectorView.setLayoutParams(lp);
mSelectorView.setAlpha(1f);
}
private float getFloat(Context ctx, TypedValue typedValue, int attrId) {
ctx.getTheme().resolveAttribute(attrId, typedValue, true);
// Android resources don't have a native float type, so we have to use strings.
return Float.valueOf(ctx.getResources().getString(typedValue.resourceId));
}
private int getInteger(Context ctx, TypedValue typedValue, int attrId) {
ctx.getTheme().resolveAttribute(attrId, typedValue, true);
return ctx.getResources().getInteger(typedValue.resourceId);
}
private int getDimension(Context ctx, TypedValue typedValue, int attrId) {
ctx.getTheme().resolveAttribute(attrId, typedValue, true);
return ctx.getResources().getDimensionPixelSize(typedValue.resourceId);
}
private static Animator createAnimator(View v, int attrId) {
Context ctx = v.getContext();
TypedValue typedValue = new TypedValue();
ctx.getTheme().resolveAttribute(attrId, typedValue, true);
Animator animator = AnimatorInflater.loadAnimator(ctx, typedValue.resourceId);
animator.setTarget(v);
return animator;
}
private boolean setIcon(final ImageView iconView, GuidedAction action) {
Drawable icon = null;
if (iconView != null) {
Context context = iconView.getContext();
icon = action.getIcon();
if (icon != null) {
// setImageDrawable resets the drawable's level unless we set the view level first.
iconView.setImageLevel(icon.getLevel());
iconView.setImageDrawable(icon);
iconView.setVisibility(View.VISIBLE);
} else {
iconView.setVisibility(View.GONE);
}
}
return icon != null;
}
/**
* @return the max height in pixels the description can be such that the
* action nicely takes up the entire screen.
*/
private int getDescriptionMaxHeight(Context context, TextView title) {
// The 2 multiplier on the title height calculation is a
// conservative estimate for font padding which can not be
// calculated at this stage since the view hasn't been rendered yet.
return (int)(mDisplayHeight - 2*mVerticalPadding - 2*mTitleMaxLines*title.getLineHeight());
}
/**
* SelectorAnimator
* Controls animation for selected item backgrounds
* TODO: Move into focus animation override?
*/
private static class SelectorAnimator extends RecyclerView.OnScrollListener {
private final View mSelectorView;
private final ViewGroup mParentView;
private volatile boolean mFadedOut = true;
SelectorAnimator(View selectorView, ViewGroup parentView) {
mSelectorView = selectorView;
mParentView = parentView;
}
// We want to fade in the selector if we've stopped scrolling on it. If
// we're scrolling, we want to ensure to dim the selector if we haven't
// already. We dim the last highlighted view so that while a user is
// scrolling, nothing is highlighted.
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
Animator animator = null;
boolean fadingOut = false;
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
// The selector starts with a height of 0. In order to scale up from
// 0, we first need the set the height to 1 and scale from there.
View focusedChild = mParentView.getFocusedChild();
if (focusedChild != null) {
int selectorHeight = mSelectorView.getHeight();
float scaleY = (float) focusedChild.getHeight() / selectorHeight;
AnimatorSet animators = (AnimatorSet)createAnimator(mSelectorView,
R.attr.guidedActionsSelectorShowAnimation);
if (mFadedOut) {
// selector is completely faded out, so we can just scale before fading in.
mSelectorView.setScaleY(scaleY);
animator = animators.getChildAnimations().get(0);
} else {
// selector is not faded out, so we must animate the scale as we fade in.
((ObjectAnimator)animators.getChildAnimations().get(1))
.setFloatValues(scaleY);
animator = animators;
}
}
} else {
animator = createAnimator(mSelectorView, R.attr.guidedActionsSelectorHideAnimation);
fadingOut = true;
}
if (animator != null) {
animator.addListener(new Listener(fadingOut));
animator.start();
}
}
/**
* Sets {@link BaseScrollAdapterFragment#mFadedOut}
* {@link BaseScrollAdapterFragment#mFadedOut} is true, iff
* {@link BaseScrollAdapterFragment#mSelectorView} has an alpha of 0
* (faded out). If false the view either has an alpha of 1 (visible) or
* is in the process of animating.
*/
private class Listener implements Animator.AnimatorListener {
private boolean mFadingOut;
private boolean mCanceled;
public Listener(boolean fadingOut) {
mFadingOut = fadingOut;
}
@Override
public void onAnimationStart(Animator animation) {
if (!mFadingOut) {
mFadedOut = false;
}
}
@Override
public void onAnimationEnd(Animator animation) {
if (!mCanceled && mFadingOut) {
mFadedOut = true;
}
}
@Override
public void onAnimationCancel(Animator animation) {
mCanceled = true;
}
@Override
public void onAnimationRepeat(Animator animation) {
}
}
}
}