1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.tv.guide;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.graphics.Rect;
22 import androidx.leanback.widget.VerticalGridView;
23 import android.util.AttributeSet;
24 import android.util.Log;
25 import android.util.Range;
26 import android.view.View;
27 import android.view.ViewTreeObserver;
28 import com.android.tv.R;
29 import com.android.tv.ui.OnRepeatedKeyInterceptListener;
30 import java.util.concurrent.TimeUnit;
31 
32 /** A {@link VerticalGridView} for the program table view. */
33 public class ProgramGrid extends VerticalGridView {
34     private static final String TAG = "ProgramGrid";
35 
36     private static final int INVALID_INDEX = -1;
37     private static final long FOCUS_AREA_RIGHT_MARGIN_MILLIS = TimeUnit.MINUTES.toMillis(15);
38 
39     private final ViewTreeObserver.OnGlobalFocusChangeListener mGlobalFocusChangeListener =
40             new ViewTreeObserver.OnGlobalFocusChangeListener() {
41                 @Override
42                 public void onGlobalFocusChanged(View oldFocus, View newFocus) {
43                     if (newFocus != mNextFocusByUpDown) {
44                         // If focus is changed by other buttons than UP/DOWN buttons,
45                         // we clear the focus state.
46                         clearUpDownFocusState(newFocus);
47                     }
48                     mNextFocusByUpDown = null;
49                     if (GuideUtils.isDescendant(ProgramGrid.this, newFocus)) {
50                         mLastFocusedView = newFocus;
51                     }
52                 }
53             };
54 
55     private final ProgramManager.Listener mProgramManagerListener =
56             new ProgramManager.ListenerAdapter() {
57                 @Override
58                 public void onTimeRangeUpdated() {
59                     // When time range is changed, we clear the focus state.
60                     clearUpDownFocusState(null);
61                 }
62             };
63 
64     private final ViewTreeObserver.OnPreDrawListener mPreDrawListener =
65             new ViewTreeObserver.OnPreDrawListener() {
66                 @Override
67                 public boolean onPreDraw() {
68                     getViewTreeObserver().removeOnPreDrawListener(this);
69                     updateInputLogo();
70                     return true;
71                 }
72             };
73 
74     private ProgramManager mProgramManager;
75     private View mNextFocusByUpDown;
76 
77     // New focus will be overlapped with [mFocusRangeLeft, mFocusRangeRight].
78     private int mFocusRangeLeft;
79     private int mFocusRangeRight;
80 
81     private final int mRowHeight;
82     private final int mDetailHeight;
83     private final int mSelectionRow; // Row that is focused
84 
85     private View mLastFocusedView;
86     private final Rect mTempRect = new Rect();
87     private int mLastUpDownDirection;
88 
89     private boolean mKeepCurrentProgramFocused;
90 
91     private ChildFocusListener mChildFocusListener;
92     private final OnRepeatedKeyInterceptListener mOnRepeatedKeyInterceptListener;
93 
94     interface ChildFocusListener {
95         /**
96          * Is called before focus is moved. Only children to {@code ProgramGrid} will be passed. See
97          * {@code ProgramGrid#setChildFocusListener(ChildFocusListener)}.
98          */
onRequestChildFocus(View oldFocus, View newFocus)99         void onRequestChildFocus(View oldFocus, View newFocus);
100     }
101 
ProgramGrid(Context context)102     public ProgramGrid(Context context) {
103         this(context, null);
104     }
105 
ProgramGrid(Context context, AttributeSet attrs)106     public ProgramGrid(Context context, AttributeSet attrs) {
107         this(context, attrs, 0);
108     }
109 
ProgramGrid(Context context, AttributeSet attrs, int defStyle)110     public ProgramGrid(Context context, AttributeSet attrs, int defStyle) {
111         super(context, attrs, defStyle);
112         clearUpDownFocusState(null);
113 
114         // Don't cache anything that is off screen. Normally it is good to prefetch and prepopulate
115         // off screen views in order to reduce jank, however the program guide is capable to scroll
116         // in all four directions so not only would we prefetch views in the scrolling direction
117         // but also keep views in the perpendicular direction up to date.
118         // E.g. when scrolling horizontally we would have to update rows above and below the current
119         // view port even though they are not visible.
120         setItemViewCacheSize(0);
121 
122         Resources res = context.getResources();
123         mRowHeight = res.getDimensionPixelSize(R.dimen.program_guide_table_item_row_height);
124         mDetailHeight = res.getDimensionPixelSize(R.dimen.program_guide_table_detail_height);
125         mSelectionRow = res.getInteger(R.integer.program_guide_selection_row);
126         mOnRepeatedKeyInterceptListener = new OnRepeatedKeyInterceptListener(this);
127         setOnKeyInterceptListener(mOnRepeatedKeyInterceptListener);
128     }
129 
130     @Override
requestChildFocus(View child, View focused)131     public void requestChildFocus(View child, View focused) {
132         if (mChildFocusListener != null) {
133             mChildFocusListener.onRequestChildFocus(getFocusedChild(), child);
134         }
135         super.requestChildFocus(child, focused);
136     }
137 
138     @Override
onAttachedToWindow()139     protected void onAttachedToWindow() {
140         super.onAttachedToWindow();
141         getViewTreeObserver().addOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
142         mProgramManager.addListener(mProgramManagerListener);
143     }
144 
145     @Override
onDetachedFromWindow()146     protected void onDetachedFromWindow() {
147         super.onDetachedFromWindow();
148         getViewTreeObserver().removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
149         mProgramManager.removeListener(mProgramManagerListener);
150         clearUpDownFocusState(null);
151     }
152 
153     @Override
focusSearch(View focused, int direction)154     public View focusSearch(View focused, int direction) {
155         mNextFocusByUpDown = null;
156         if (focused == null || (focused != this && !GuideUtils.isDescendant(this, focused))) {
157             return super.focusSearch(focused, direction);
158         }
159         if (direction == View.FOCUS_UP || direction == View.FOCUS_DOWN) {
160             updateUpDownFocusState(focused, direction);
161             View nextFocus = focusFind(focused, direction);
162             if (nextFocus != null) {
163                 return nextFocus;
164             }
165         }
166         return super.focusSearch(focused, direction);
167     }
168 
169     @Override
onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect)170     public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
171         if (mLastFocusedView != null && mLastFocusedView.isShown()) {
172             if (mLastFocusedView.requestFocus()) {
173                 return true;
174             }
175         }
176         return super.onRequestFocusInDescendants(direction, previouslyFocusedRect);
177     }
178 
179     @Override
onScrollChanged(int l, int t, int oldl, int oldt)180     protected void onScrollChanged(int l, int t, int oldl, int oldt) {
181         // It is required to properly handle OnRepeatedKeyInterceptListener. If the focused
182         // item's are at the almost end of screen, focus change to the next item doesn't work.
183         // It restricts that a focus item's position cannot be too far from the desired position.
184         View focusedView = findFocus();
185         if (focusedView != null && mOnRepeatedKeyInterceptListener.isFocusAccelerated()) {
186             int[] location = new int[2];
187             getLocationOnScreen(location);
188             int[] focusedLocation = new int[2];
189             focusedView.getLocationOnScreen(focusedLocation);
190             int y = focusedLocation[1] - location[1];
191             int minY = (mSelectionRow - 1) * mRowHeight;
192             if (y < minY) scrollBy(0, y - minY);
193             int maxY = (mSelectionRow + 1) * mRowHeight + mDetailHeight;
194             if (y > maxY) scrollBy(0, y - maxY);
195         }
196         updateInputLogo();
197     }
198 
199     @Override
onViewRemoved(View view)200     public void onViewRemoved(View view) {
201         // It is required to ensure input logo showing when the scroll is moved to most bottom.
202         updateInputLogo();
203     }
204 
205     /**
206      * Initializes ProgramGrid. It should be called before the view is actually attached to Window.
207      */
initialize(ProgramManager programManager)208     void initialize(ProgramManager programManager) {
209         mProgramManager = programManager;
210     }
211 
212     /** Registers a listener focus events occurring on children to the {@code ProgramGrid}. */
setChildFocusListener(ChildFocusListener childFocusListener)213     void setChildFocusListener(ChildFocusListener childFocusListener) {
214         mChildFocusListener = childFocusListener;
215     }
216 
onItemSelectionReset()217     void onItemSelectionReset() {
218         getViewTreeObserver().addOnPreDrawListener(mPreDrawListener);
219     }
220 
221     /**
222      * Resets focus states. If the logic to keep the last focus needs to be cleared, it should be
223      * called.
224      */
resetFocusState()225     void resetFocusState() {
226         mLastFocusedView = null;
227         clearUpDownFocusState(null);
228     }
229 
230     /** Returns the currently focused item's horizontal range. */
getFocusRange()231     Range<Integer> getFocusRange() {
232         return new Range<>(mFocusRangeLeft, mFocusRangeRight);
233     }
234 
235     /** Returns if the next focused item should be the current program if possible. */
isKeepCurrentProgramFocused()236     boolean isKeepCurrentProgramFocused() {
237         return mKeepCurrentProgramFocused;
238     }
239 
240     /** Returns the last up/down move direction of browsing */
getLastUpDownDirection()241     int getLastUpDownDirection() {
242         return mLastUpDownDirection;
243     }
244 
focusFind(View focused, int direction)245     private View focusFind(View focused, int direction) {
246         int focusedChildIndex = getFocusedChildIndex();
247         if (focusedChildIndex == INVALID_INDEX) {
248             Log.w(TAG, "No child view has focus");
249             return null;
250         }
251         int nextChildIndex =
252                 direction == View.FOCUS_UP ? focusedChildIndex - 1 : focusedChildIndex + 1;
253         if (nextChildIndex < 0 || nextChildIndex >= getChildCount()) {
254             // Wraparound if reached head or end
255             if (getSelectedPosition() == 0) {
256                 scrollToPosition(getAdapter().getItemCount() - 1);
257                 return null;
258             } else if (getSelectedPosition() == getAdapter().getItemCount() - 1) {
259                 int itemCount = getLayoutManager().getItemCount();
260                 int childCount = getChildCount();
261                 // b/129466363 For an item which overalps with previous layout GridLayoutManager
262                 // will scroll to first child of current layout, instead of going to previous one.
263                 // smoothscrollToPosition will invalidate all layouts and scroll to position 0.
264                 // This condition checks for an item which overlaps with the first layout
265                 if (itemCount > 2 * (childCount + 1) || itemCount <= childCount) {
266                     scrollToPosition(0);
267                     return null;
268                 } else {
269                     smoothScrollToPosition(0);
270                     return getChildAt(0);
271                 }
272             }
273             return focused;
274         }
275         View nextFocusedProgram =
276                 GuideUtils.findNextFocusedProgram(
277                         getChildAt(nextChildIndex),
278                         mFocusRangeLeft,
279                         mFocusRangeRight,
280                         mKeepCurrentProgramFocused);
281         if (nextFocusedProgram != null) {
282             nextFocusedProgram.getGlobalVisibleRect(mTempRect);
283             mNextFocusByUpDown = nextFocusedProgram;
284 
285         } else {
286             Log.w(TAG, "focusFind doesn't find proper focusable");
287         }
288         return nextFocusedProgram;
289     }
290 
291     // Returned value is not the position of VerticalGridView. But it's the index of ViewGroup
292     // among visible children.
getFocusedChildIndex()293     private int getFocusedChildIndex() {
294         for (int i = 0; i < getChildCount(); ++i) {
295             if (getChildAt(i).hasFocus()) {
296                 return i;
297             }
298         }
299         return INVALID_INDEX;
300     }
301 
updateUpDownFocusState(View focused, int direction)302     private void updateUpDownFocusState(View focused, int direction) {
303         mLastUpDownDirection = direction;
304         int rightMostFocusablePosition = getRightMostFocusablePosition();
305         Rect focusedRect = mTempRect;
306 
307         // In order to avoid from focusing small width item, we clip the position with
308         // mostRightFocusablePosition.
309         focused.getGlobalVisibleRect(focusedRect);
310         mFocusRangeLeft = Math.min(mFocusRangeLeft, rightMostFocusablePosition);
311         mFocusRangeRight = Math.min(mFocusRangeRight, rightMostFocusablePosition);
312         focusedRect.left = Math.min(focusedRect.left, rightMostFocusablePosition);
313         focusedRect.right = Math.min(focusedRect.right, rightMostFocusablePosition);
314 
315         if (focusedRect.left > mFocusRangeRight || focusedRect.right < mFocusRangeLeft) {
316             Log.w(TAG, "The current focus is out of [mFocusRangeLeft, mFocusRangeRight]");
317             mFocusRangeLeft = focusedRect.left;
318             mFocusRangeRight = focusedRect.right;
319             return;
320         }
321         mFocusRangeLeft = Math.max(mFocusRangeLeft, focusedRect.left);
322         mFocusRangeRight = Math.min(mFocusRangeRight, focusedRect.right);
323     }
324 
clearUpDownFocusState(View focus)325     private void clearUpDownFocusState(View focus) {
326         mLastUpDownDirection = 0;
327         mFocusRangeLeft = 0;
328         mFocusRangeRight = getRightMostFocusablePosition();
329         mNextFocusByUpDown = null;
330         // If focus is not a program item, drop focus to the current program when back to the grid
331         mKeepCurrentProgramFocused =
332                 !(focus instanceof ProgramItemView)
333                         || GuideUtils.isCurrentProgram((ProgramItemView) focus);
334     }
335 
getRightMostFocusablePosition()336     private int getRightMostFocusablePosition() {
337         if (!getGlobalVisibleRect(mTempRect)) {
338             return Integer.MAX_VALUE;
339         }
340         return mTempRect.right - GuideUtils.convertMillisToPixel(FOCUS_AREA_RIGHT_MARGIN_MILLIS);
341     }
342 
getFirstVisibleChildIndex()343     private int getFirstVisibleChildIndex() {
344         final LayoutManager mLayoutManager = getLayoutManager();
345         int top = mLayoutManager.getPaddingTop();
346         int childCount = getChildCount();
347         for (int i = 0; i < childCount; i++) {
348             View childView = getChildAt(i);
349             int childTop = mLayoutManager.getDecoratedTop(childView);
350             int childBottom = mLayoutManager.getDecoratedBottom(childView);
351             if ((childTop + childBottom) / 2 > top) {
352                 return i;
353             }
354         }
355         return -1;
356     }
357 
updateInputLogo()358     private void updateInputLogo() {
359         int childCount = getChildCount();
360         if (childCount == 0) {
361             return;
362         }
363         int firstVisibleChildIndex = getFirstVisibleChildIndex();
364         if (firstVisibleChildIndex == -1) {
365             return;
366         }
367         View childView = getChildAt(firstVisibleChildIndex);
368         int childAdapterPosition = getChildAdapterPosition(childView);
369         ((ProgramTableAdapter.ProgramRowViewHolder) getChildViewHolder(childView))
370                 .updateInputLogo(childAdapterPosition, true);
371         for (int i = firstVisibleChildIndex + 1; i < childCount; i++) {
372             childView = getChildAt(i);
373             ((ProgramTableAdapter.ProgramRowViewHolder) getChildViewHolder(childView))
374                     .updateInputLogo(childAdapterPosition, false);
375             childAdapterPosition = getChildAdapterPosition(childView);
376         }
377     }
378 }
379