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.graphics.Rect; 21 import androidx.recyclerview.widget.LinearLayoutManager; 22 import android.util.AttributeSet; 23 import android.util.Log; 24 import android.util.Range; 25 import android.view.View; 26 import android.view.ViewTreeObserver.OnGlobalLayoutListener; 27 import com.android.tv.data.api.Channel; 28 import com.android.tv.guide.ProgramManager.TableEntry; 29 import com.android.tv.util.Utils; 30 import java.util.concurrent.TimeUnit; 31 32 public class ProgramRow extends TimelineGridView { 33 private static final String TAG = "ProgramRow"; 34 private static final boolean DEBUG = false; 35 36 private static final long ONE_HOUR_MILLIS = TimeUnit.HOURS.toMillis(1); 37 private static final long HALF_HOUR_MILLIS = ONE_HOUR_MILLIS / 2; 38 39 private ProgramGuide mProgramGuide; 40 private ProgramManager mProgramManager; 41 42 private boolean mKeepFocusToCurrentProgram; 43 private ChildFocusListener mChildFocusListener; 44 45 interface ChildFocusListener { 46 /** 47 * Is called after focus is moved. Caller should check if old and new focuses are listener's 48 * children. See {@code ProgramRow#setChildFocusListener(ChildFocusListener)}. 49 */ onChildFocus(View oldFocus, View newFocus)50 void onChildFocus(View oldFocus, View newFocus); 51 } 52 53 /** Used only for debugging. */ 54 private Channel mChannel; 55 56 private final OnGlobalLayoutListener mLayoutListener = 57 new OnGlobalLayoutListener() { 58 @Override 59 public void onGlobalLayout() { 60 getViewTreeObserver().removeOnGlobalLayoutListener(this); 61 updateChildVisibleArea(); 62 } 63 }; 64 ProgramRow(Context context)65 public ProgramRow(Context context) { 66 this(context, null); 67 } 68 ProgramRow(Context context, AttributeSet attrs)69 public ProgramRow(Context context, AttributeSet attrs) { 70 this(context, attrs, 0); 71 } 72 ProgramRow(Context context, AttributeSet attrs, int defStyle)73 public ProgramRow(Context context, AttributeSet attrs, int defStyle) { 74 super(context, attrs, defStyle); 75 ProgramRowAccessibilityDelegate rowAccessibilityDelegate = 76 new ProgramRowAccessibilityDelegate(this); 77 this.setAccessibilityDelegateCompat(rowAccessibilityDelegate); 78 } 79 80 /** Registers a listener focus events occurring on children to the {@code ProgramRow}. */ setChildFocusListener(ChildFocusListener childFocusListener)81 public void setChildFocusListener(ChildFocusListener childFocusListener) { 82 mChildFocusListener = childFocusListener; 83 } 84 85 @Override onViewAdded(View child)86 public void onViewAdded(View child) { 87 super.onViewAdded(child); 88 ProgramItemView itemView = (ProgramItemView) child; 89 if (getLeft() <= itemView.getRight() && itemView.getLeft() <= getRight()) { 90 itemView.updateVisibleArea(); 91 } 92 } 93 94 @Override onScrolled(int dx, int dy)95 public void onScrolled(int dx, int dy) { 96 // Remove callback to prevent updateChildVisibleArea being called twice. 97 getViewTreeObserver().removeOnGlobalLayoutListener(mLayoutListener); 98 super.onScrolled(dx, dy); 99 if (DEBUG) { 100 Log.d(TAG, "onScrolled by " + dx); 101 Log.d(TAG, "channelId=" + mChannel.getId() + ", childCount=" + getChildCount()); 102 Log.d(TAG, "ProgramRow {" + Utils.toRectString(this) + "}"); 103 } 104 updateChildVisibleArea(); 105 } 106 107 /** Moves focus to the current program. */ focusCurrentProgram()108 public void focusCurrentProgram() { 109 View currentProgram = getCurrentProgramView(); 110 if (currentProgram == null) { 111 currentProgram = getChildAt(0); 112 } 113 if (mChildFocusListener != null) { 114 mChildFocusListener.onChildFocus(null, currentProgram); 115 } 116 } 117 118 // Call this API after RTL is resolved. (i.e. View is measured.) isDirectionStart(int direction)119 private boolean isDirectionStart(int direction) { 120 return getLayoutDirection() == LAYOUT_DIRECTION_LTR 121 ? direction == View.FOCUS_LEFT 122 : direction == View.FOCUS_RIGHT; 123 } 124 125 // Call this API after RTL is resolved. (i.e. View is measured.) isDirectionEnd(int direction)126 private boolean isDirectionEnd(int direction) { 127 return getLayoutDirection() == LAYOUT_DIRECTION_LTR 128 ? direction == View.FOCUS_RIGHT 129 : direction == View.FOCUS_LEFT; 130 } 131 132 // When Accessibility is enabled, this API will keep next node visible focusSearchAccessibility(View focused, int direction)133 void focusSearchAccessibility(View focused, int direction) { 134 TableEntry focusedEntry = ((ProgramItemView) focused).getTableEntry(); 135 long toMillis = mProgramManager.getToUtcMillis(); 136 137 if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) { 138 if (focusedEntry.entryEndUtcMillis >= toMillis) { 139 scrollByTime(focusedEntry.entryEndUtcMillis - toMillis + HALF_HOUR_MILLIS); 140 } 141 } 142 } 143 144 @Override focusSearch(View focused, int direction)145 public View focusSearch(View focused, int direction) { 146 TableEntry focusedEntry = ((ProgramItemView) focused).getTableEntry(); 147 long fromMillis = mProgramManager.getFromUtcMillis(); 148 long toMillis = mProgramManager.getToUtcMillis(); 149 150 if (!mProgramGuide.isAccessibilityEnabled() 151 && (isDirectionStart(direction) || direction == View.FOCUS_BACKWARD)) { 152 if (focusedEntry.entryStartUtcMillis < fromMillis) { 153 // The current entry starts outside of the view; Align or scroll to the left. 154 scrollByTime( 155 Math.max(-ONE_HOUR_MILLIS, focusedEntry.entryStartUtcMillis - fromMillis)); 156 return focused; 157 } 158 } else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) { 159 if (focusedEntry.entryEndUtcMillis >= toMillis + ONE_HOUR_MILLIS) { 160 // The current entry ends outside of the view; Scroll to the right. 161 scrollByTime(ONE_HOUR_MILLIS); 162 return focused; 163 } 164 } 165 166 View target = super.focusSearch(focused, direction); 167 if (!(target instanceof ProgramItemView)) { 168 if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) { 169 if (focusedEntry.entryEndUtcMillis != toMillis) { 170 // The focused entry is the last entry; Align to the right edge. 171 scrollByTime(focusedEntry.entryEndUtcMillis - toMillis); 172 return focused; 173 } 174 } 175 return target; 176 } 177 178 TableEntry targetEntry = ((ProgramItemView) target).getTableEntry(); 179 180 if (isDirectionStart(direction) || direction == View.FOCUS_BACKWARD) { 181 if (mProgramGuide.isAccessibilityEnabled()) { 182 scrollByTime(targetEntry.entryStartUtcMillis - fromMillis); 183 } else if (targetEntry.entryStartUtcMillis < fromMillis 184 && targetEntry.entryEndUtcMillis < fromMillis + HALF_HOUR_MILLIS) { 185 // The target entry starts outside the view; Align or scroll to the left. 186 scrollByTime( 187 Math.max(-ONE_HOUR_MILLIS, targetEntry.entryStartUtcMillis - fromMillis)); 188 } 189 } else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) { 190 if (targetEntry.entryStartUtcMillis > fromMillis + ONE_HOUR_MILLIS + HALF_HOUR_MILLIS) { 191 // The target entry starts outside the view; Align or scroll to the right. 192 scrollByTime( 193 Math.min( 194 ONE_HOUR_MILLIS, 195 targetEntry.entryStartUtcMillis - fromMillis - ONE_HOUR_MILLIS)); 196 } 197 } 198 199 return target; 200 } 201 scrollByTime(long timeToScroll)202 private void scrollByTime(long timeToScroll) { 203 if (DEBUG) { 204 Log.d( 205 TAG, 206 "scrollByTime(timeToScroll=" 207 + TimeUnit.MILLISECONDS.toMinutes(timeToScroll) 208 + "min)"); 209 } 210 mProgramManager.shiftTime(timeToScroll); 211 } 212 213 @Override onChildDetachedFromWindow(View child)214 public void onChildDetachedFromWindow(View child) { 215 if (child.hasFocus()) { 216 // Focused view can be detached only if it's updated. 217 TableEntry entry = ((ProgramItemView) child).getTableEntry(); 218 if (entry.program == null) { 219 // The focus is lost due to information loaded. Requests focus immediately. 220 // (Because this entry is detached after real entries attached, we can't take 221 // the below approach to resume focus on entry being attached.) 222 post( 223 new Runnable() { 224 @Override 225 public void run() { 226 requestFocus(); 227 } 228 }); 229 } else if (entry.isCurrentProgram()) { 230 if (DEBUG) Log.d(TAG, "Keep focus to the current program"); 231 // Current program is visible in the guide. 232 // Updated entries including current program's will be attached again soon 233 // so give focus back in onChildAttachedToWindow(). 234 mKeepFocusToCurrentProgram = true; 235 } 236 } 237 super.onChildDetachedFromWindow(child); 238 } 239 240 @Override onChildAttachedToWindow(View child)241 public void onChildAttachedToWindow(View child) { 242 super.onChildAttachedToWindow(child); 243 if (mKeepFocusToCurrentProgram) { 244 TableEntry entry = ((ProgramItemView) child).getTableEntry(); 245 if (entry.isCurrentProgram()) { 246 mKeepFocusToCurrentProgram = false; 247 post( 248 new Runnable() { 249 @Override 250 public void run() { 251 requestFocus(); 252 } 253 }); 254 } 255 } 256 } 257 258 @Override onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect)259 public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { 260 ProgramGrid programGrid = mProgramGuide.getProgramGrid(); 261 262 // Give focus according to the previous focused range 263 Range<Integer> focusRange = programGrid.getFocusRange(); 264 View nextFocus = 265 GuideUtils.findNextFocusedProgram( 266 this, 267 focusRange.getLower(), 268 focusRange.getUpper(), 269 programGrid.isKeepCurrentProgramFocused()); 270 271 if (nextFocus != null) { 272 return nextFocus.requestFocus(); 273 } 274 275 if (DEBUG) Log.d(TAG, "onRequestFocusInDescendants"); 276 boolean result = super.onRequestFocusInDescendants(direction, previouslyFocusedRect); 277 if (!result) { 278 // The default focus search logic of LeanbackLibrary is sometimes failed. 279 // As a fallback solution, we request focus to the first focusable view. 280 for (int i = 0; i < getChildCount(); ++i) { 281 View child = getChildAt(i); 282 if (child.isShown() && child.hasFocusable()) { 283 return child.requestFocus(); 284 } 285 } 286 } 287 return result; 288 } 289 getCurrentProgramView()290 private View getCurrentProgramView() { 291 for (int i = 0; i < getChildCount(); ++i) { 292 TableEntry entry = ((ProgramItemView) getChildAt(i)).getTableEntry(); 293 if (entry.isCurrentProgram()) { 294 return getChildAt(i); 295 } 296 } 297 return null; 298 } 299 setChannel(Channel channel)300 public void setChannel(Channel channel) { 301 mChannel = channel; 302 } 303 304 /** Sets the instance of {@link ProgramGuide} */ setProgramGuide(ProgramGuide programGuide)305 public void setProgramGuide(ProgramGuide programGuide) { 306 mProgramGuide = programGuide; 307 mProgramManager = programGuide.getProgramManager(); 308 } 309 310 /** Resets the scroll with the initial offset {@code scrollOffset}. */ resetScroll(int scrollOffset)311 public void resetScroll(int scrollOffset) { 312 long startTime = 313 GuideUtils.convertPixelToMillis(scrollOffset) + mProgramManager.getStartTime(); 314 int position = 315 mChannel == null 316 ? -1 317 : mProgramManager.getProgramIndexAtTime(mChannel.getId(), startTime); 318 if (position < 0) { 319 getLayoutManager().scrollToPosition(0); 320 } else { 321 TableEntry entry = mProgramManager.getTableEntry(mChannel.getId(), position); 322 int offset = 323 GuideUtils.convertMillisToPixel( 324 mProgramManager.getStartTime(), entry.entryStartUtcMillis) 325 - scrollOffset; 326 ((LinearLayoutManager) getLayoutManager()).scrollToPositionWithOffset(position, offset); 327 // Workaround to b/31598505. When a program's duration is too long, 328 // RecyclerView.onScrolled() will not be called after scrollToPositionWithOffset(). 329 // Therefore we have to update children's visible areas by ourselves in this case. 330 // Since scrollToPositionWithOffset() will call requestLayout(), we can listen to this 331 // behavior to ensure program items' visible areas are correctly updated after layouts 332 // are adjusted, i.e., scrolling is over. 333 getViewTreeObserver().addOnGlobalLayoutListener(mLayoutListener); 334 } 335 } 336 updateChildVisibleArea()337 private void updateChildVisibleArea() { 338 for (int i = 0; i < getChildCount(); ++i) { 339 ProgramItemView child = (ProgramItemView) getChildAt(i); 340 if (getLeft() < child.getRight() && child.getLeft() < getRight()) { 341 child.updateVisibleArea(); 342 } 343 } 344 } 345 } 346