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