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