1 /* 2 * Copyright (C) 2020 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 package com.android.car.ui.core; 17 18 import android.app.Activity; 19 import android.content.res.TypedArray; 20 import android.os.Build; 21 import android.view.LayoutInflater; 22 import android.view.View; 23 import android.view.ViewGroup; 24 import android.view.ViewTreeObserver; 25 import android.widget.FrameLayout; 26 27 import androidx.annotation.LayoutRes; 28 import androidx.annotation.NonNull; 29 import androidx.annotation.Nullable; 30 import androidx.fragment.app.Fragment; 31 import androidx.fragment.app.FragmentActivity; 32 33 import com.android.car.ui.R; 34 import com.android.car.ui.baselayout.Insets; 35 import com.android.car.ui.baselayout.InsetsChangedListener; 36 import com.android.car.ui.toolbar.ToolbarController; 37 import com.android.car.ui.toolbar.ToolbarControllerImpl; 38 import com.android.car.ui.utils.CarUiUtils; 39 40 import java.util.Map; 41 import java.util.WeakHashMap; 42 43 /** 44 * BaseLayoutController accepts an {@link Activity} and sets up the base layout inside of it. 45 * It also exposes a {@link ToolbarController} to access the toolbar. This may be null if 46 * used with a base layout without a Toolbar. 47 */ 48 class BaseLayoutController { 49 50 private static final Map<Activity, BaseLayoutController> sBaseLayoutMap = new WeakHashMap<>(); 51 52 private InsetsUpdater mInsetsUpdater; 53 54 /** 55 * Gets a BaseLayoutController for the given {@link Activity}. Must have called 56 * {@link #build(Activity)} with the same activity earlier, otherwise will return null. 57 */ 58 @Nullable getBaseLayout(Activity activity)59 /* package */ static BaseLayoutController getBaseLayout(Activity activity) { 60 return sBaseLayoutMap.get(activity); 61 } 62 63 @Nullable 64 private ToolbarController mToolbarController; 65 BaseLayoutController(Activity activity)66 private BaseLayoutController(Activity activity) { 67 installBaseLayout(activity); 68 } 69 70 /** 71 * Create a new BaseLayoutController for the given {@link Activity}. 72 * 73 * <p>You can get a reference to it by calling {@link #getBaseLayout(Activity)}. 74 */ 75 /* package */ build(Activity activity)76 static void build(Activity activity) { 77 if (getThemeBoolean(activity, R.attr.carUiBaseLayout)) { 78 sBaseLayoutMap.put(activity, new BaseLayoutController(activity)); 79 } 80 } 81 82 /** 83 * Destroy the BaseLayoutController for the given {@link Activity}. 84 */ 85 /* package */ destroy(Activity activity)86 static void destroy(Activity activity) { 87 sBaseLayoutMap.remove(activity); 88 } 89 90 /** 91 * Gets the {@link ToolbarController} for activities created with carUiBaseLayout and 92 * carUiToolbar set to true. 93 */ 94 @Nullable getToolbarController()95 /* package */ ToolbarController getToolbarController() { 96 return mToolbarController; 97 } 98 getInsets()99 /* package */ Insets getInsets() { 100 return mInsetsUpdater.getInsets(); 101 } 102 dispatchNewInsets(Insets insets)103 /* package */ void dispatchNewInsets(Insets insets) { 104 mInsetsUpdater.dispatchNewInsets(insets); 105 } 106 replaceInsetsChangedListenerWith(InsetsChangedListener listener)107 /* package */ void replaceInsetsChangedListenerWith(InsetsChangedListener listener) { 108 mInsetsUpdater.replaceInsetsChangedListenerWith(listener); 109 } 110 111 /** 112 * Installs the base layout into an activity, moving its content view under the base layout. 113 * 114 * <p>This function must be called during the onCreate() of the {@link Activity}. 115 * 116 * @param activity The {@link Activity} to install a base layout in. 117 */ installBaseLayout(Activity activity)118 private void installBaseLayout(Activity activity) { 119 boolean toolbarEnabled = getThemeBoolean(activity, R.attr.carUiToolbar); 120 boolean legacyToolbar = Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q; 121 @LayoutRes final int baseLayoutRes; 122 123 if (toolbarEnabled) { 124 baseLayoutRes = legacyToolbar 125 ? R.layout.car_ui_base_layout_toolbar_legacy 126 : R.layout.car_ui_base_layout_toolbar; 127 } else { 128 baseLayoutRes = R.layout.car_ui_base_layout; 129 } 130 131 View baseLayout = LayoutInflater.from(activity) 132 .inflate(baseLayoutRes, null, false); 133 134 // Replace windowContentView with baseLayout 135 ViewGroup windowContentView = CarUiUtils.findViewByRefId( 136 activity.getWindow().getDecorView(), android.R.id.content); 137 ViewGroup contentViewParent = (ViewGroup) windowContentView.getParent(); 138 int contentIndex = contentViewParent.indexOfChild(windowContentView); 139 contentViewParent.removeView(windowContentView); 140 contentViewParent.addView(baseLayout, contentIndex, windowContentView.getLayoutParams()); 141 142 // Add windowContentView to the baseLayout's content view 143 FrameLayout contentView = CarUiUtils.requireViewByRefId(baseLayout, R.id.content); 144 contentView.addView(windowContentView, new FrameLayout.LayoutParams( 145 ViewGroup.LayoutParams.MATCH_PARENT, 146 ViewGroup.LayoutParams.MATCH_PARENT)); 147 148 if (toolbarEnabled) { 149 if (legacyToolbar) { 150 mToolbarController = CarUiUtils.requireViewByRefId(baseLayout, R.id.car_ui_toolbar); 151 } else { 152 mToolbarController = new ToolbarControllerImpl(baseLayout); 153 } 154 } 155 156 mInsetsUpdater = new InsetsUpdater(activity, baseLayout, windowContentView); 157 mInsetsUpdater.installListeners(); 158 } 159 160 /** 161 * Gets the boolean value of an Attribute from an {@link Activity Activity's} 162 * {@link android.content.res.Resources.Theme}. 163 */ getThemeBoolean(Activity activity, int attr)164 private static boolean getThemeBoolean(Activity activity, int attr) { 165 TypedArray a = activity.getTheme().obtainStyledAttributes(new int[]{attr}); 166 167 try { 168 return a.getBoolean(0, false); 169 } finally { 170 a.recycle(); 171 } 172 } 173 174 /** 175 * InsetsUpdater waits for layout changes, and when there is one, calculates the appropriate 176 * insets into the content view. 177 * 178 * <p>It then calls {@link InsetsChangedListener#onCarUiInsetsChanged(Insets)} on the 179 * {@link Activity} and any {@link Fragment Fragments} the Activity might have. If 180 * none of the Activity/Fragments implement {@link InsetsChangedListener}, it will set 181 * padding on the content view equal to the insets. 182 */ 183 private static class InsetsUpdater implements ViewTreeObserver.OnGlobalLayoutListener { 184 // These tags mark views that should overlay the content view in the base layout. 185 // OEMs should add them to views in their base layout, ie: android:tag="car_ui_left_inset" 186 // Apps will then be able to draw under these views, but will be encouraged to not put 187 // any user-interactable content there. 188 private static final String LEFT_INSET_TAG = "car_ui_left_inset"; 189 private static final String RIGHT_INSET_TAG = "car_ui_right_inset"; 190 private static final String TOP_INSET_TAG = "car_ui_top_inset"; 191 private static final String BOTTOM_INSET_TAG = "car_ui_bottom_inset"; 192 193 private final Activity mActivity; 194 private final View mLeftInsetView; 195 private final View mRightInsetView; 196 private final View mTopInsetView; 197 private final View mBottomInsetView; 198 private InsetsChangedListener mInsetsChangedListenerDelegate; 199 200 private boolean mInsetsDirty = true; 201 @NonNull 202 private Insets mInsets = new Insets(); 203 204 /** 205 * Constructs an InsetsUpdater that calculates and dispatches insets to an {@link Activity}. 206 * 207 * @param activity The activity that is using base layouts 208 * @param baseLayout The root view of the base layout 209 * @param contentView The android.R.id.content View 210 */ InsetsUpdater(Activity activity, View baseLayout, View contentView)211 InsetsUpdater(Activity activity, View baseLayout, View contentView) { 212 mActivity = activity; 213 214 mLeftInsetView = baseLayout.findViewWithTag(LEFT_INSET_TAG); 215 mRightInsetView = baseLayout.findViewWithTag(RIGHT_INSET_TAG); 216 mTopInsetView = baseLayout.findViewWithTag(TOP_INSET_TAG); 217 mBottomInsetView = baseLayout.findViewWithTag(BOTTOM_INSET_TAG); 218 219 final View.OnLayoutChangeListener layoutChangeListener = 220 (View v, int left, int top, int right, int bottom, 221 int oldLeft, int oldTop, int oldRight, int oldBottom) -> { 222 if (left != oldLeft || top != oldTop 223 || right != oldRight || bottom != oldBottom) { 224 mInsetsDirty = true; 225 } 226 }; 227 228 if (mLeftInsetView != null) { 229 mLeftInsetView.addOnLayoutChangeListener(layoutChangeListener); 230 } 231 if (mRightInsetView != null) { 232 mRightInsetView.addOnLayoutChangeListener(layoutChangeListener); 233 } 234 if (mTopInsetView != null) { 235 mTopInsetView.addOnLayoutChangeListener(layoutChangeListener); 236 } 237 if (mBottomInsetView != null) { 238 mBottomInsetView.addOnLayoutChangeListener(layoutChangeListener); 239 } 240 contentView.addOnLayoutChangeListener(layoutChangeListener); 241 } 242 243 /** 244 * Install a global layout listener, during which the insets will be recalculated and 245 * dispatched. 246 */ installListeners()247 void installListeners() { 248 // The global layout listener will run after all the individual layout change listeners 249 // so that we only updateInsets once per layout, even if multiple inset views changed 250 mActivity.getWindow().getDecorView().getViewTreeObserver() 251 .addOnGlobalLayoutListener(this); 252 } 253 254 @NonNull getInsets()255 Insets getInsets() { 256 return mInsets; 257 } 258 replaceInsetsChangedListenerWith(InsetsChangedListener listener)259 public void replaceInsetsChangedListenerWith(InsetsChangedListener listener) { 260 mInsetsChangedListenerDelegate = listener; 261 } 262 263 /** 264 * onGlobalLayout() should recalculate the amount of insets we need, and then dispatch them. 265 */ 266 @Override onGlobalLayout()267 public void onGlobalLayout() { 268 if (!mInsetsDirty) { 269 return; 270 } 271 272 View content = CarUiUtils.requireViewByRefId(mActivity.getWindow().getDecorView(), 273 android.R.id.content); 274 275 // Calculate how much each inset view overlays the content view 276 int top = 0; 277 int left = 0; 278 int right = 0; 279 int bottom = 0; 280 if (mTopInsetView != null) { 281 top = Math.max(0, getBottomOfView(mTopInsetView) - getTopOfView(content)); 282 } 283 if (mBottomInsetView != null) { 284 bottom = Math.max(0, getBottomOfView(content) - getTopOfView(mBottomInsetView)); 285 } 286 if (mLeftInsetView != null) { 287 left = Math.max(0, getRightOfView(mLeftInsetView) - getLeftOfView(content)); 288 } 289 if (mRightInsetView != null) { 290 right = Math.max(0, getRightOfView(content) - getLeftOfView(mRightInsetView)); 291 } 292 Insets insets = new Insets(left, top, right, bottom); 293 294 mInsetsDirty = false; 295 if (!insets.equals(mInsets)) { 296 mInsets = insets; 297 dispatchNewInsets(insets); 298 } 299 } 300 301 /** 302 * Dispatch the new {@link Insets} to the {@link Activity} and all of its 303 * {@link Fragment Fragments}. If none of those implement {@link InsetsChangedListener}, 304 * we will set the value of the insets as padding on the content view. 305 * 306 * @param insets The newly-changed insets. 307 */ dispatchNewInsets(Insets insets)308 /* package */ void dispatchNewInsets(Insets insets) { 309 mInsets = insets; 310 311 boolean handled = false; 312 313 if (mInsetsChangedListenerDelegate != null) { 314 mInsetsChangedListenerDelegate.onCarUiInsetsChanged(insets); 315 handled = true; 316 } else { 317 // If an explicit InsetsChangedListener is not provided, 318 // pass the insets to activities and fragments 319 if (mActivity instanceof InsetsChangedListener) { 320 ((InsetsChangedListener) mActivity).onCarUiInsetsChanged(insets); 321 handled = true; 322 } 323 324 if (mActivity instanceof FragmentActivity) { 325 for (Fragment fragment : ((FragmentActivity) mActivity) 326 .getSupportFragmentManager().getFragments()) { 327 if (fragment instanceof InsetsChangedListener) { 328 ((InsetsChangedListener) fragment).onCarUiInsetsChanged(insets); 329 handled = true; 330 } 331 } 332 } 333 } 334 335 if (!handled) { 336 CarUiUtils.requireViewByRefId(mActivity.getWindow().getDecorView(), 337 android.R.id.content).setPadding(insets.getLeft(), insets.getTop(), 338 insets.getRight(), insets.getBottom()); 339 } 340 } 341 getLeftOfView(View v)342 private static int getLeftOfView(View v) { 343 int[] position = new int[2]; 344 v.getLocationOnScreen(position); 345 return position[0]; 346 } 347 getRightOfView(View v)348 private static int getRightOfView(View v) { 349 int[] position = new int[2]; 350 v.getLocationOnScreen(position); 351 return position[0] + v.getWidth(); 352 } 353 getTopOfView(View v)354 private static int getTopOfView(View v) { 355 int[] position = new int[2]; 356 v.getLocationOnScreen(position); 357 return position[1]; 358 } 359 getBottomOfView(View v)360 private static int getBottomOfView(View v) { 361 int[] position = new int[2]; 362 v.getLocationOnScreen(position); 363 return position[1] + v.getHeight(); 364 } 365 } 366 } 367