blob: 96e161cde6360edd7f4c33f73559ddfb6c68b6c0 [file] [log] [blame]
/*
* 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 com.android.tv.guide;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Rect;
import androidx.leanback.widget.VerticalGridView;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Range;
import android.view.View;
import android.view.ViewTreeObserver;
import com.android.tv.R;
import com.android.tv.ui.OnRepeatedKeyInterceptListener;
import java.util.concurrent.TimeUnit;
/** A {@link VerticalGridView} for the program table view. */
public class ProgramGrid extends VerticalGridView {
private static final String TAG = "ProgramGrid";
private static final int INVALID_INDEX = -1;
private static final long FOCUS_AREA_RIGHT_MARGIN_MILLIS = TimeUnit.MINUTES.toMillis(15);
private final ViewTreeObserver.OnGlobalFocusChangeListener mGlobalFocusChangeListener =
new ViewTreeObserver.OnGlobalFocusChangeListener() {
@Override
public void onGlobalFocusChanged(View oldFocus, View newFocus) {
if (newFocus != mNextFocusByUpDown) {
// If focus is changed by other buttons than UP/DOWN buttons,
// we clear the focus state.
clearUpDownFocusState(newFocus);
}
mNextFocusByUpDown = null;
if (GuideUtils.isDescendant(ProgramGrid.this, newFocus)) {
mLastFocusedView = newFocus;
}
}
};
private final ProgramManager.Listener mProgramManagerListener =
new ProgramManager.ListenerAdapter() {
@Override
public void onTimeRangeUpdated() {
// When time range is changed, we clear the focus state.
clearUpDownFocusState(null);
}
};
private final ViewTreeObserver.OnPreDrawListener mPreDrawListener =
new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
getViewTreeObserver().removeOnPreDrawListener(this);
updateInputLogo();
return true;
}
};
private ProgramManager mProgramManager;
private View mNextFocusByUpDown;
// New focus will be overlapped with [mFocusRangeLeft, mFocusRangeRight].
private int mFocusRangeLeft;
private int mFocusRangeRight;
private final int mRowHeight;
private final int mDetailHeight;
private final int mSelectionRow; // Row that is focused
private View mLastFocusedView;
private final Rect mTempRect = new Rect();
private int mLastUpDownDirection;
private boolean mKeepCurrentProgramFocused;
private ChildFocusListener mChildFocusListener;
private final OnRepeatedKeyInterceptListener mOnRepeatedKeyInterceptListener;
interface ChildFocusListener {
/**
* Is called before focus is moved. Only children to {@code ProgramGrid} will be passed. See
* {@code ProgramGrid#setChildFocusListener(ChildFocusListener)}.
*/
void onRequestChildFocus(View oldFocus, View newFocus);
}
public ProgramGrid(Context context) {
this(context, null);
}
public ProgramGrid(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ProgramGrid(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
clearUpDownFocusState(null);
// Don't cache anything that is off screen. Normally it is good to prefetch and prepopulate
// off screen views in order to reduce jank, however the program guide is capable to scroll
// in all four directions so not only would we prefetch views in the scrolling direction
// but also keep views in the perpendicular direction up to date.
// E.g. when scrolling horizontally we would have to update rows above and below the current
// view port even though they are not visible.
setItemViewCacheSize(0);
Resources res = context.getResources();
mRowHeight = res.getDimensionPixelSize(R.dimen.program_guide_table_item_row_height);
mDetailHeight = res.getDimensionPixelSize(R.dimen.program_guide_table_detail_height);
mSelectionRow = res.getInteger(R.integer.program_guide_selection_row);
mOnRepeatedKeyInterceptListener = new OnRepeatedKeyInterceptListener(this);
setOnKeyInterceptListener(mOnRepeatedKeyInterceptListener);
}
@Override
public void requestChildFocus(View child, View focused) {
if (mChildFocusListener != null) {
mChildFocusListener.onRequestChildFocus(getFocusedChild(), child);
}
super.requestChildFocus(child, focused);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
getViewTreeObserver().addOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
mProgramManager.addListener(mProgramManagerListener);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
getViewTreeObserver().removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
mProgramManager.removeListener(mProgramManagerListener);
clearUpDownFocusState(null);
}
@Override
public View focusSearch(View focused, int direction) {
mNextFocusByUpDown = null;
if (focused == null || (focused != this && !GuideUtils.isDescendant(this, focused))) {
return super.focusSearch(focused, direction);
}
if (direction == View.FOCUS_UP || direction == View.FOCUS_DOWN) {
updateUpDownFocusState(focused, direction);
View nextFocus = focusFind(focused, direction);
if (nextFocus != null) {
return nextFocus;
}
}
return super.focusSearch(focused, direction);
}
@Override
public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
if (mLastFocusedView != null && mLastFocusedView.isShown()) {
if (mLastFocusedView.requestFocus()) {
return true;
}
}
return super.onRequestFocusInDescendants(direction, previouslyFocusedRect);
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
// It is required to properly handle OnRepeatedKeyInterceptListener. If the focused
// item's are at the almost end of screen, focus change to the next item doesn't work.
// It restricts that a focus item's position cannot be too far from the desired position.
View focusedView = findFocus();
if (focusedView != null && mOnRepeatedKeyInterceptListener.isFocusAccelerated()) {
int[] location = new int[2];
getLocationOnScreen(location);
int[] focusedLocation = new int[2];
focusedView.getLocationOnScreen(focusedLocation);
int y = focusedLocation[1] - location[1];
int minY = (mSelectionRow - 1) * mRowHeight;
if (y < minY) scrollBy(0, y - minY);
int maxY = (mSelectionRow + 1) * mRowHeight + mDetailHeight;
if (y > maxY) scrollBy(0, y - maxY);
}
updateInputLogo();
}
@Override
public void onViewRemoved(View view) {
// It is required to ensure input logo showing when the scroll is moved to most bottom.
updateInputLogo();
}
/**
* Initializes ProgramGrid. It should be called before the view is actually attached to Window.
*/
void initialize(ProgramManager programManager) {
mProgramManager = programManager;
}
/** Registers a listener focus events occurring on children to the {@code ProgramGrid}. */
void setChildFocusListener(ChildFocusListener childFocusListener) {
mChildFocusListener = childFocusListener;
}
void onItemSelectionReset() {
getViewTreeObserver().addOnPreDrawListener(mPreDrawListener);
}
/**
* Resets focus states. If the logic to keep the last focus needs to be cleared, it should be
* called.
*/
void resetFocusState() {
mLastFocusedView = null;
clearUpDownFocusState(null);
}
/** Returns the currently focused item's horizontal range. */
Range<Integer> getFocusRange() {
return new Range<>(mFocusRangeLeft, mFocusRangeRight);
}
/** Returns if the next focused item should be the current program if possible. */
boolean isKeepCurrentProgramFocused() {
return mKeepCurrentProgramFocused;
}
/** Returns the last up/down move direction of browsing */
int getLastUpDownDirection() {
return mLastUpDownDirection;
}
private View focusFind(View focused, int direction) {
int focusedChildIndex = getFocusedChildIndex();
if (focusedChildIndex == INVALID_INDEX) {
Log.w(TAG, "No child view has focus");
return null;
}
int nextChildIndex =
direction == View.FOCUS_UP ? focusedChildIndex - 1 : focusedChildIndex + 1;
if (nextChildIndex < 0 || nextChildIndex >= getChildCount()) {
// Wraparound if reached head or end
if (getSelectedPosition() == 0) {
scrollToPosition(getAdapter().getItemCount() - 1);
return null;
} else if (getSelectedPosition() == getAdapter().getItemCount() - 1) {
int itemCount = getLayoutManager().getItemCount();
int childCount = getChildCount();
// b/129466363 For an item which overalps with previous layout GridLayoutManager
// will scroll to first child of current layout, instead of going to previous one.
// smoothscrollToPosition will invalidate all layouts and scroll to position 0.
// This condition checks for an item which overlaps with the first layout
if (itemCount > 2 * (childCount + 1) || itemCount <= childCount) {
scrollToPosition(0);
return null;
} else {
smoothScrollToPosition(0);
return getChildAt(0);
}
}
return focused;
}
View nextFocusedProgram =
GuideUtils.findNextFocusedProgram(
getChildAt(nextChildIndex),
mFocusRangeLeft,
mFocusRangeRight,
mKeepCurrentProgramFocused);
if (nextFocusedProgram != null) {
nextFocusedProgram.getGlobalVisibleRect(mTempRect);
mNextFocusByUpDown = nextFocusedProgram;
} else {
Log.w(TAG, "focusFind doesn't find proper focusable");
}
return nextFocusedProgram;
}
// Returned value is not the position of VerticalGridView. But it's the index of ViewGroup
// among visible children.
private int getFocusedChildIndex() {
for (int i = 0; i < getChildCount(); ++i) {
if (getChildAt(i).hasFocus()) {
return i;
}
}
return INVALID_INDEX;
}
private void updateUpDownFocusState(View focused, int direction) {
mLastUpDownDirection = direction;
int rightMostFocusablePosition = getRightMostFocusablePosition();
Rect focusedRect = mTempRect;
// In order to avoid from focusing small width item, we clip the position with
// mostRightFocusablePosition.
focused.getGlobalVisibleRect(focusedRect);
mFocusRangeLeft = Math.min(mFocusRangeLeft, rightMostFocusablePosition);
mFocusRangeRight = Math.min(mFocusRangeRight, rightMostFocusablePosition);
focusedRect.left = Math.min(focusedRect.left, rightMostFocusablePosition);
focusedRect.right = Math.min(focusedRect.right, rightMostFocusablePosition);
if (focusedRect.left > mFocusRangeRight || focusedRect.right < mFocusRangeLeft) {
Log.w(TAG, "The current focus is out of [mFocusRangeLeft, mFocusRangeRight]");
mFocusRangeLeft = focusedRect.left;
mFocusRangeRight = focusedRect.right;
return;
}
mFocusRangeLeft = Math.max(mFocusRangeLeft, focusedRect.left);
mFocusRangeRight = Math.min(mFocusRangeRight, focusedRect.right);
}
private void clearUpDownFocusState(View focus) {
mLastUpDownDirection = 0;
mFocusRangeLeft = 0;
mFocusRangeRight = getRightMostFocusablePosition();
mNextFocusByUpDown = null;
// If focus is not a program item, drop focus to the current program when back to the grid
mKeepCurrentProgramFocused =
!(focus instanceof ProgramItemView)
|| GuideUtils.isCurrentProgram((ProgramItemView) focus);
}
private int getRightMostFocusablePosition() {
if (!getGlobalVisibleRect(mTempRect)) {
return Integer.MAX_VALUE;
}
return mTempRect.right - GuideUtils.convertMillisToPixel(FOCUS_AREA_RIGHT_MARGIN_MILLIS);
}
private int getFirstVisibleChildIndex() {
final LayoutManager mLayoutManager = getLayoutManager();
int top = mLayoutManager.getPaddingTop();
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
int childTop = mLayoutManager.getDecoratedTop(childView);
int childBottom = mLayoutManager.getDecoratedBottom(childView);
if ((childTop + childBottom) / 2 > top) {
return i;
}
}
return -1;
}
private void updateInputLogo() {
int childCount = getChildCount();
if (childCount == 0) {
return;
}
int firstVisibleChildIndex = getFirstVisibleChildIndex();
if (firstVisibleChildIndex == -1) {
return;
}
View childView = getChildAt(firstVisibleChildIndex);
int childAdapterPosition = getChildAdapterPosition(childView);
((ProgramTableAdapter.ProgramRowViewHolder) getChildViewHolder(childView))
.updateInputLogo(childAdapterPosition, true);
for (int i = firstVisibleChildIndex + 1; i < childCount; i++) {
childView = getChildAt(i);
((ProgramTableAdapter.ProgramRowViewHolder) getChildViewHolder(childView))
.updateInputLogo(childAdapterPosition, false);
childAdapterPosition = getChildAdapterPosition(childView);
}
}
}