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