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