Home | History | Annotate | Download | only in guide
      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