1 /*
2  * Copyright (C) 2017 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.car.apps.common;
18 
19 import android.content.Context;
20 import android.content.res.TypedArray;
21 import android.graphics.drawable.Drawable;
22 import android.transition.ChangeBounds;
23 import android.transition.Fade;
24 import android.transition.TransitionManager;
25 import android.transition.TransitionSet;
26 import android.util.AttributeSet;
27 import android.util.Log;
28 import android.util.SparseArray;
29 import android.view.LayoutInflater;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.widget.FrameLayout;
33 import android.widget.ImageButton;
34 import android.widget.LinearLayout;
35 import android.widget.RelativeLayout;
36 import android.widget.Space;
37 
38 import androidx.annotation.NonNull;
39 import androidx.annotation.Nullable;
40 import androidx.annotation.VisibleForTesting;
41 import androidx.core.util.Preconditions;
42 import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
43 
44 import java.util.Locale;
45 
46 
47 /**
48  * An actions panel with three distinctive zones:
49  * <ul>
50  * <li>Main control: located in the bottom center it shows a highlighted icon and a circular
51  * progress bar.
52  * <li>Secondary controls: these are displayed at the left and at the right of the main control.
53  * <li>Overflow controls: these are displayed at the left and at the right of the secondary controls
54  * (if the space allows) and on the additional space if the panel is expanded.
55  * </ul>
56  */
57 public class ControlBar extends RelativeLayout implements ExpandableControlBar {
58     private static final String TAG = "ControlBar";
59 
60     // Rows container
61     private ViewGroup mRowsContainer;
62     // All slots in this action bar where 0 is the bottom-start corner of the matrix, and
63     // mNumColumns * nNumRows - 1 is the top-end corner
64     private FrameLayout[] mSlots;
65     /**
66      * Reference to the first slot we create. Used to properly inflate buttons without loosing
67      * their layout params.
68      */
69     private FrameLayout mFirstCreatedSlot;
70     /** Views to set in particular {@link SlotPosition}s */
71     private final SparseArray<View> mFixedViews = new SparseArray<>();
72     // View to be used for the expand/collapse action
73     private @Nullable View mExpandCollapseView;
74     // Default expand/collapse view to use one is not provided.
75     private View mDefaultExpandCollapseView;
76     // Number of rows in actual use. This is the number of extra rows that will be displayed when
77     // the action bar is expanded
78     private int mNumExtraRowsInUse;
79     // Whether the action bar is expanded or not.
80     private boolean mIsExpanded;
81     // Views to accomodate in the slots.
82     private @Nullable View[] mViews;
83     // Number of columns of slots to use.
84     private int mNumColumns;
85     // Maximum number of rows to use (at least one!).
86     private int mNumRows;
87     // Whether the expand button should be visible or not
88     private boolean mExpandEnabled;
89     // Callback for the expand/collapse button
90     private ExpandCollapseCallback mExpandCollapseCallback;
91 
92     // Default number of columns, if unspecified
93     private static final int DEFAULT_COLUMNS = 3;
94     // Weight for the spacers used between buttons
95     private static final float SPACERS_WEIGHT = 1f;
96 
ControlBar(Context context)97     public ControlBar(Context context) {
98         super(context);
99         init(context, null, 0, 0);
100     }
101 
ControlBar(Context context, AttributeSet attrs)102     public ControlBar(Context context, AttributeSet attrs) {
103         super(context, attrs);
104         init(context, attrs, 0, 0);
105     }
106 
ControlBar(Context context, AttributeSet attrs, int defStyleAttrs)107     public ControlBar(Context context, AttributeSet attrs, int defStyleAttrs) {
108         super(context, attrs, defStyleAttrs);
109         init(context, attrs, defStyleAttrs, 0);
110     }
111 
ControlBar(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)112     public ControlBar(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) {
113         super(context, attrs, defStyleAttrs, defStyleRes);
114         init(context, attrs, defStyleAttrs, defStyleRes);
115     }
116 
init(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)117     private void init(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) {
118         inflate(context, R.layout.control_bar, this);
119 
120         TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ControlBar,
121                 defStyleAttrs, defStyleRes);
122         mNumColumns = ta.getInteger(R.styleable.ControlBar_columns, DEFAULT_COLUMNS);
123         mExpandEnabled = ta.getBoolean(R.styleable.ControlBar_enableOverflow, true);
124         ta.recycle();
125 
126         mRowsContainer = findViewById(R.id.rows_container);
127         mNumRows = mRowsContainer.getChildCount();
128         Preconditions.checkState(mNumRows > 0, "Must have at least 1 row");
129 
130         mSlots = new FrameLayout[mNumColumns * mNumRows];
131 
132         LayoutInflater inflater = LayoutInflater.from(context);
133         final boolean attachToRoot = false;
134 
135         for (int i = 0; i < mNumRows; i++) {
136             // Slots are reserved in reverse order (first slots are in the bottom row)
137             ViewGroup row = (ViewGroup) mRowsContainer.getChildAt(mNumRows - i - 1);
138             // Inflate necessary number of columns
139             for (int j = 0; j < mNumColumns; j++) {
140                 int pos = i * mNumColumns + j;
141                 mSlots[pos] = (FrameLayout) inflater.inflate(R.layout.control_bar_slot, row,
142                         attachToRoot);
143                 if (mFirstCreatedSlot == null) {
144                     mFirstCreatedSlot = mSlots[pos];
145                 }
146                 if (j > 0) {
147                     Space space = new Space(context);
148                     row.addView(space);
149                     space.setLayoutParams(new LinearLayout.LayoutParams(0,
150                             ViewGroup.LayoutParams.MATCH_PARENT, SPACERS_WEIGHT));
151                 }
152                 row.addView(mSlots[pos]);
153             }
154         }
155 
156         mDefaultExpandCollapseView = createIconButton(
157                 context.getDrawable(R.drawable.ic_overflow_button));
158         mDefaultExpandCollapseView.setContentDescription(context.getString(
159                 R.string.control_bar_expand_collapse_button));
160         mDefaultExpandCollapseView.setOnClickListener(v -> onExpandCollapse());
161     }
162 
getSlotIndex(@lotPosition int slotPosition)163     private int getSlotIndex(@SlotPosition int slotPosition) {
164         return CarControlBar.getSlotIndex(slotPosition, mNumColumns);
165     }
166 
167     @Override
setView(@ullable View view, @SlotPosition int slotPosition)168     public void setView(@Nullable View view, @SlotPosition int slotPosition) {
169         if (view != null) {
170             mFixedViews.put(slotPosition, view);
171         } else {
172             mFixedViews.remove(slotPosition);
173         }
174         updateViewsLayout();
175     }
176 
177     /**
178      * Sets the view to use for the expand/collapse action. If not provided, a default
179      * {@link ImageButton} will be used. The provided {@link View} should be able be able to display
180      * changes in the "activated" state appropriately.
181      *
182      * @param view {@link View} to use for the expand/collapse action.
183      */
setExpandCollapseView(@onNull View view)184     public void setExpandCollapseView(@NonNull View view) {
185         mExpandCollapseView = view;
186         mExpandCollapseView.setOnClickListener(v -> onExpandCollapse());
187         updateViewsLayout();
188     }
189 
getExpandCollapseView()190     private View getExpandCollapseView() {
191         return mExpandCollapseView != null ? mExpandCollapseView : mDefaultExpandCollapseView;
192     }
193 
194     @Override
createIconButton(Drawable icon)195     public ImageButton createIconButton(Drawable icon) {
196         return createIconButton(icon, R.layout.control_bar_button);
197     }
198 
199     @Override
createIconButton(Drawable icon, int viewId)200     public ImageButton createIconButton(Drawable icon, int viewId) {
201         LayoutInflater inflater = LayoutInflater.from(mFirstCreatedSlot.getContext());
202         final boolean attachToRoot = false;
203         ImageButton button = (ImageButton) inflater.inflate(viewId, mFirstCreatedSlot,
204                 attachToRoot);
205         button.setImageDrawable(icon);
206         return button;
207     }
208 
209     @Override
registerExpandCollapseCallback(@ullable ExpandCollapseCallback callback)210     public void registerExpandCollapseCallback(@Nullable ExpandCollapseCallback callback) {
211         mExpandCollapseCallback = callback;
212     }
213 
214     @Override
close()215     public void close() {
216         if (mIsExpanded) {
217             onExpandCollapse();
218         }
219     }
220 
221     @Override
setViews(@ullable View[] views)222     public void setViews(@Nullable View[] views) {
223         mViews = views;
224         updateViewsLayout();
225     }
226 
updateViewsLayout()227     private void updateViewsLayout() {
228         // Prepare an array of positions taken
229         int totalSlots = mSlots.length;
230         View[] slotViews = new View[totalSlots];
231 
232         // Take all known positions
233         for (int i = 0; i < mFixedViews.size(); i++) {
234             int index = getSlotIndex(mFixedViews.keyAt(i));
235             if (index >= 0 && index < slotViews.length) {
236                 slotViews[index] = mFixedViews.valueAt(i);
237             }
238         }
239 
240         // Set all views using both the fixed and flexible positions
241         int expandCollapseIndex = getSlotIndex(SLOT_EXPAND_COLLAPSE);
242         int lastUsedIndex = 0;
243         int viewsIndex = 0;
244         for (int i = 0; i < totalSlots; i++) {
245             View viewToUse = null;
246 
247             if (slotViews[i] != null) {
248                 // If there is a view assigned for this slot, use it.
249                 viewToUse = slotViews[i];
250             } else if (mExpandEnabled && i == expandCollapseIndex && mViews != null
251                     && viewsIndex < mViews.length - 1) {
252                 // If this is the expand/collapse slot, use the corresponding view
253                 viewToUse = getExpandCollapseView();
254                 Log.d(TAG, "" + this + "Setting expand control");
255             } else if (mViews != null && viewsIndex < mViews.length) {
256                 // Otherwise, if the slot is not reserved, and if we still have views to assign,
257                 // take one and assign it to this slot.
258                 viewToUse = mViews[viewsIndex];
259                 viewsIndex++;
260             }
261             setView(viewToUse, mSlots[i]);
262             if (viewToUse != null) {
263                 lastUsedIndex = i;
264             }
265         }
266 
267         mNumExtraRowsInUse = lastUsedIndex / mNumColumns;
268         final int lastIndex = lastUsedIndex;
269 
270         if (mNumRows > 1) {
271             // Align expanded control bar rows
272             mRowsContainer.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
273                 for (int i  = 1; i < mNumRows; i++) {
274                     // mRowsContainer's children are in reverse order (last row is at index 0)
275                     int rowIndex = mNumRows - 1 - i;
276                     if (lastIndex < (i + 1) * mNumColumns) {
277                         // Align the last row's center with the first row by translating the last
278                         // row by half the difference between the two rows' length.
279                         // We use the position of the last slot as a proxy for the length, since the
280                         // slots have the same size, and both rows have the same start point.
281                         float lastRowX = mSlots[lastIndex].getX();
282                         float firstRowX = mSlots[mNumColumns - 1].getX();
283                         mRowsContainer.getChildAt(rowIndex).setTranslationX(
284                                 (firstRowX - lastRowX) / 2);
285                     } else {
286                         mRowsContainer.getChildAt(rowIndex).setTranslationX(0);
287                     }
288                 }
289             });
290         }
291     }
292 
setView(@ullable View view, FrameLayout container)293     private void setView(@Nullable View view, FrameLayout container) {
294         if (view != null) {
295             // Don't set the view if it stays the same.
296             if (container.getChildCount() == 1 && container.getChildAt(0) == view) {
297                 return;
298             }
299 
300             ViewGroup parent = (ViewGroup) view.getParent();
301             // As we are removing views (on BT disconnect, for example), some items will be
302             // shifting from expanded to collapsed (like Queue item) - remove those from the
303             // group before adding to the new slot
304             if (view.getParent() != null) {
305                 parent.removeView(view);
306             }
307             container.removeAllViews();
308             container.addView(view);
309             container.setVisibility(VISIBLE);
310         } else {
311             if (container.getChildCount() != 0) {
312                 container.removeAllViews();
313             }
314             container.setVisibility(INVISIBLE);
315         }
316     }
317 
onExpandCollapse()318     private void onExpandCollapse() {
319         mIsExpanded = !mIsExpanded;
320         if (mExpandCollapseView != null) {
321             mExpandCollapseView.setSelected(mIsExpanded);
322         }
323         if (mExpandCollapseCallback != null) {
324             mExpandCollapseCallback.onExpandCollapse(mIsExpanded);
325         }
326         mSlots[getSlotIndex(SLOT_EXPAND_COLLAPSE)].setActivated(mIsExpanded);
327 
328         int animationDuration = getContext().getResources().getInteger(mIsExpanded
329                 ? R.integer.control_bar_expand_anim_duration
330                 : R.integer.control_bar_collapse_anim_duration);
331         TransitionSet set = new TransitionSet()
332                 .addTransition(new ChangeBounds())
333                 .addTransition(new Fade())
334                 .setDuration(animationDuration)
335                 .setInterpolator(new FastOutSlowInInterpolator());
336         TransitionManager.beginDelayedTransition(this, set);
337         for (int i = 0; i < mNumExtraRowsInUse; i++) {
338             mRowsContainer.getChildAt(i).setVisibility(mIsExpanded ? View.VISIBLE : View.GONE);
339         }
340     }
341 
342     /**
343      * Returns the view assigned to the given row and column, after layout.
344      *
345      * @param rowIdx row index from 0 being the top row, and {@link #mNumRows{ -1 being the bottom
346      *               row.
347      * @param colIdx column index from 0 on start (left), to {@link #mNumColumns} on end (right)
348      */
349     @VisibleForTesting
350     @Nullable
getViewAt(int rowIdx, int colIdx)351     View getViewAt(int rowIdx, int colIdx) {
352         if (rowIdx < 0 || rowIdx > mRowsContainer.getChildCount()) {
353             throw new IllegalArgumentException(String.format((Locale) null,
354                     "Row index out of range (requested: %d, max: %d)",
355                     rowIdx, mRowsContainer.getChildCount()));
356         }
357         if (colIdx < 0 || colIdx > mNumColumns) {
358             throw new IllegalArgumentException(String.format((Locale) null,
359                     "Column index out of range (requested: %d, max: %d)",
360                     colIdx, mNumColumns));
361         }
362         FrameLayout slot = (FrameLayout) ((LinearLayout) mRowsContainer.getChildAt(rowIdx))
363                 .getChildAt(colIdx + 1);
364         return slot.getChildCount() > 0 ? slot.getChildAt(0) : null;
365     }
366 }
367