/* * Copyright (C) 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.complication; import static com.android.systemui.complication.ComplicationLayoutParams.DIRECTION_DOWN; import static com.android.systemui.complication.ComplicationLayoutParams.DIRECTION_END; import static com.android.systemui.complication.ComplicationLayoutParams.DIRECTION_START; import static com.android.systemui.complication.ComplicationLayoutParams.DIRECTION_UP; import static com.android.systemui.complication.ComplicationLayoutParams.POSITION_BOTTOM; import static com.android.systemui.complication.ComplicationLayoutParams.POSITION_END; import static com.android.systemui.complication.ComplicationLayoutParams.POSITION_START; import static com.android.systemui.complication.ComplicationLayoutParams.POSITION_TOP; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.COMPLICATIONS_FADE_IN_DURATION; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.COMPLICATIONS_FADE_OUT_DURATION; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.COMPLICATION_DIRECTIONAL_SPACING_DEFAULT; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.COMPLICATION_MARGIN_POSITION_BOTTOM; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.COMPLICATION_MARGIN_POSITION_END; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.COMPLICATION_MARGIN_POSITION_START; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.COMPLICATION_MARGIN_POSITION_TOP; import static com.android.systemui.complication.dagger.ComplicationHostViewModule.SCOPED_COMPLICATIONS_LAYOUT; import android.util.Log; import android.view.View; import android.view.ViewGroup; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.Constraints; import com.android.systemui.R; import com.android.systemui.complication.ComplicationLayoutParams.Direction; import com.android.systemui.complication.ComplicationLayoutParams.Position; import com.android.systemui.complication.dagger.ComplicationModule; import com.android.systemui.statusbar.CrossFadeHelper; import com.android.systemui.touch.TouchInsetManager; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.function.Consumer; import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Named; /** * {@link ComplicationLayoutEngine} arranges a collection of {@link ComplicationViewModel} based on * their layout parameters and attributes. The management of this set is done by * {@link ComplicationHostViewController}. */ @ComplicationModule.ComplicationScope public class ComplicationLayoutEngine implements Complication.VisibilityController { public static final String TAG = "ComplicationLayoutEng"; /** * Container for storing and operating on a tuple of margin values. */ public static class Margins { public final int start; public final int top; public final int end; public final int bottom; /** * Default constructor with all margins set to 0. */ public Margins() { this(0, 0, 0, 0); } /** * Cosntructor to specify margin in each direction. * @param start start margin * @param top top margin * @param end end margin * @param bottom bottom margin */ public Margins(int start, int top, int end, int bottom) { this.start = start; this.top = top; this.end = end; this.bottom = bottom; } /** * Creates a new {@link Margins} by adding the corresponding dimensions together. */ public static Margins combine(Margins margins1, Margins margins2) { return new Margins(margins1.start + margins2.start, margins1.top + margins2.top, margins1.end + margins2.end, margins1.bottom + margins2.bottom); } } /** * {@link ViewEntry} is an internal container, capturing information necessary for working with * a particular {@link Complication} view. */ private static class ViewEntry implements Comparable { private final View mView; private final ComplicationLayoutParams mLayoutParams; private final TouchInsetManager.TouchInsetSession mTouchInsetSession; private final Parent mParent; @Complication.Category private final int mCategory; /** * Default constructor. {@link Parent} allows for the {@link ViewEntry}'s surrounding * view hierarchy to be accessed without traversing the entire view tree. */ ViewEntry(View view, ComplicationLayoutParams layoutParams, TouchInsetManager.TouchInsetSession touchSession, int category, Parent parent) { mView = view; // Views that are generated programmatically do not have a unique id assigned to them // at construction. A new id is assigned here to enable ConstraintLayout relative // specifications. Existing ids for inflated views are not preserved. // {@link Complication.ViewHolder} should not reference the root container by id. mView.setId(View.generateViewId()); mLayoutParams = layoutParams; mTouchInsetSession = touchSession; mCategory = category; mParent = parent; touchSession.addViewToTracking(mView); } /** * Returns the {@link View} associated with the {@link Complication}. This is the instance * passed in at construction. The reference to this {@link View} is captured when the * {@link Complication} is added to the {@link ComplicationLayoutEngine}. The * {@link Complication} cannot modify the {@link View} reference beyond this point. */ private View getView() { return mView; } /** * Returns The {@link ComplicationLayoutParams} associated with the view. */ public ComplicationLayoutParams getLayoutParams() { return mLayoutParams; } /** * Interprets the {@link #getLayoutParams()} into {@link ConstraintLayout.LayoutParams} and * applies them to the view. The method accounts for the relationship of the {@link View} to * the other {@link Complication} views around it. The organization of the {@link View} * instances in {@link ComplicationLayoutEngine} can be seen as lists. A {@link View} is * either the head of its list or a following node. This head is passed into this method, * which can be a reference to the {@link View} to indicate it is the head. */ public void applyLayoutParams(View head) { // Only the basic dimension parameters from the base ViewGroup.LayoutParams are carried // over verbatim from the complication specified LayoutParam. Other fields are // interpreted. final ConstraintLayout.LayoutParams params = new Constraints.LayoutParams(mLayoutParams.width, mLayoutParams.height); final int direction = getLayoutParams().getDirection(); final boolean snapsToGuide = getLayoutParams().snapsToGuide(); // If no parent, view is the anchor. In this case, it is given the highest priority for // alignment. All alignment preferences are done in relation to the parent container. final boolean isRoot = head == mView; // Each view can be seen as a vector, having a point (described here as position) and // direction. When a view is the head of a position, then it is the first in a sequence // of complications to appear from that position. For example, being the head for // position POSITION_TOP | POSITION_END will cause the view to be shown as the first // view in that corner. In this case, the positions specify which sides to align with // the parent. If the view is not the head, the positions perpendicular to the direction // of the view specify which side to align with the opposing side of the head view. // Otherwise, the position aligns with the containing view. This means a // POSITION_BOTTOM | POSITION_START with DIRECTION_UP non-head view's bottom to be // aligned with the preceding view node's top and start to be aligned with the // parent's start. mLayoutParams.iteratePositions(position -> { switch(position) { case ComplicationLayoutParams.POSITION_START: if (isRoot || direction != ComplicationLayoutParams.DIRECTION_END) { params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; } else { params.startToEnd = head.getId(); } if (snapsToGuide && (direction == ComplicationLayoutParams.DIRECTION_DOWN || direction == ComplicationLayoutParams.DIRECTION_UP)) { params.endToStart = R.id.complication_start_guide; } break; case ComplicationLayoutParams.POSITION_TOP: if (isRoot || direction != ComplicationLayoutParams.DIRECTION_DOWN) { params.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; } else { params.topToBottom = head.getId(); } if (snapsToGuide && (direction == ComplicationLayoutParams.DIRECTION_END || direction == ComplicationLayoutParams.DIRECTION_START)) { params.endToStart = R.id.complication_top_guide; } break; case ComplicationLayoutParams.POSITION_BOTTOM: if (isRoot || direction != ComplicationLayoutParams.DIRECTION_UP) { params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID; } else { params.bottomToTop = head.getId(); } if (snapsToGuide && (direction == ComplicationLayoutParams.DIRECTION_END || direction == ComplicationLayoutParams.DIRECTION_START)) { params.topToBottom = R.id.complication_bottom_guide; } break; case ComplicationLayoutParams.POSITION_END: if (isRoot || direction != ComplicationLayoutParams.DIRECTION_START) { params.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; } else { params.endToStart = head.getId(); } if (snapsToGuide && (direction == ComplicationLayoutParams.DIRECTION_UP || direction == ComplicationLayoutParams.DIRECTION_DOWN)) { params.startToEnd = R.id.complication_end_guide; } break; } final Margins margins = mParent.getMargins(this, isRoot); params.setMarginsRelative(margins.start, margins.top, margins.end, margins.bottom); }); if (mLayoutParams.constraintSpecified()) { switch (direction) { case ComplicationLayoutParams.DIRECTION_START: case ComplicationLayoutParams.DIRECTION_END: params.matchConstraintMaxWidth = mLayoutParams.getConstraint(); break; case ComplicationLayoutParams.DIRECTION_UP: case ComplicationLayoutParams.DIRECTION_DOWN: params.matchConstraintMaxHeight = mLayoutParams.getConstraint(); break; } } mView.setLayoutParams(params); } private void setGuide(ConstraintLayout.LayoutParams lp, int validDirections, Consumer consumer) { final ComplicationLayoutParams layoutParams = getLayoutParams(); if (!layoutParams.snapsToGuide()) { return; } consumer.accept(lp); } /** * Informs the {@link ViewEntry}'s parent entity to remove the {@link ViewEntry} from * being shown further. */ public void remove() { mParent.removeEntry(this); ((ViewGroup) mView.getParent()).removeView(mView); mTouchInsetSession.removeViewFromTracking(mView); } @Override public int compareTo(ViewEntry viewEntry) { // If the two entries have different categories, system complications take precedence. if (viewEntry.mCategory != mCategory) { // Note that this logic will need to be adjusted if more categories are introduced. return mCategory == Complication.CATEGORY_SYSTEM ? 1 : -1; } // A higher weight indicates greater precedence if all else being equal. if (viewEntry.mLayoutParams.getWeight() != mLayoutParams.getWeight()) { return mLayoutParams.getWeight() > viewEntry.mLayoutParams.getWeight() ? 1 : -1; } return 0; } /** * {@link Builder} allows for a multiple entities to contribute to the {@link ViewEntry} * construction. This is necessary for setting an immutable parent, which might not be * known until the view hierarchy is traversed. */ private static class Builder { private final View mView; private final TouchInsetManager.TouchInsetSession mTouchSession; private final ComplicationLayoutParams mLayoutParams; private final int mCategory; private Parent mParent; Builder(View view, TouchInsetManager.TouchInsetSession touchSession, ComplicationLayoutParams lp, @Complication.Category int category) { mView = view; mLayoutParams = lp; mCategory = category; mTouchSession = touchSession; } /** * Returns the set {@link ComplicationLayoutParams} */ public ComplicationLayoutParams getLayoutParams() { return mLayoutParams; } /** * Returns the set {@link Complication.Category}. */ @Complication.Category public int getCategory() { return mCategory; } /** * Sets the parent. Note that this references to the entity for handling events, such as * requesting the removal of the {@link View}. It is not the * {@link android.view.ViewGroup} which contains the {@link View}. */ Builder setParent(Parent parent) { mParent = parent; return this; } /** * Builds and returns the resulting {@link ViewEntry}. */ ViewEntry build() { return new ViewEntry(mView, mLayoutParams, mTouchSession, mCategory, mParent); } } /** * An interface allowing an {@link ViewEntry} to signal events. */ interface Parent { /** * Indicates the {@link ViewEntry} requests removal. */ void removeEntry(ViewEntry entry); /** * Returns the margins to be applied to the entry */ Margins getMargins(ViewEntry entry, boolean isRoot); } } /** * {@link PositionGroup} represents a collection of {@link Complication} at a given location. * It further organizes the {@link Complication} by the direction in which they emanate from * this position. */ private static class PositionGroup implements DirectionGroup.Parent { private final HashMap mDirectionGroups = new HashMap<>(); private final HashMap mDirectionalMargins; private final int mDefaultDirectionalSpacing; PositionGroup(int defaultDirectionalSpacing, HashMap directionalMargins) { mDefaultDirectionalSpacing = defaultDirectionalSpacing; mDirectionalMargins = directionalMargins; } /** * Invoked by the {@link PositionGroup} holder to introduce a {@link Complication} view to * this group. It is assumed that the caller has correctly identified this * {@link PositionGroup} as the proper home for the {@link Complication} based on its * declared position. */ public ViewEntry add(ViewEntry.Builder entryBuilder) { final int direction = entryBuilder.getLayoutParams().getDirection(); if (!mDirectionGroups.containsKey(direction)) { mDirectionGroups.put(direction, new DirectionGroup(this)); } return mDirectionGroups.get(direction).add(entryBuilder); } @Override public void onEntriesChanged() { // Whenever an entry is added/removed from a child {@link DirectionGroup}, it is vital // that all {@link DirectionGroup} children are visited. It is possible the overall // head has changed, requiring constraints to be adjusted. updateViews(); } @Override public int getDefaultDirectionalSpacing() { return mDefaultDirectionalSpacing; } @Override public Margins getMargins(ViewEntry entry, boolean isRoot) { if (isRoot) { Margins cumulativeMargins = new Margins(); for (Margins margins : mDirectionalMargins.values()) { cumulativeMargins = Margins.combine(margins, cumulativeMargins); } return cumulativeMargins; } return mDirectionalMargins.get(entry.getLayoutParams().getDirection()); } private void updateViews() { ViewEntry head = null; // Identify which {@link Complication} head from the set of {@link DirectionGroup} // should be treated as the {@link PositionGroup} head. for (DirectionGroup directionGroup : mDirectionGroups.values()) { final ViewEntry groupHead = directionGroup.getHead(); if (head == null || (groupHead != null && groupHead.compareTo(head) > 0)) { head = groupHead; } } // A headless position group indicates no complications. if (head == null) { return; } for (DirectionGroup directionGroup : mDirectionGroups.values()) { // Tell each {@link DirectionGroup} to update its containing {@link ViewEntry} based // on the identified head. This iteration will also capture any newly added views. directionGroup.updateViews(head.getView()); } } private ArrayList getViews() { final ArrayList views = new ArrayList<>(); for (DirectionGroup directionGroup : mDirectionGroups.values()) { views.addAll(directionGroup.getViews()); } return views; } } /** * A {@link DirectionGroup} organizes the {@link ViewEntry} of a parent group that point are * laid out in the same direction. */ private static class DirectionGroup implements ViewEntry.Parent { /** * An interface implemented by the {@link DirectionGroup} parent to receive updates. */ interface Parent { /** * Invoked to indicate a change to the {@link ViewEntry} composition for this * {@link DirectionGroup}. */ void onEntriesChanged(); /** * Returns the default spacing between elements. */ int getDefaultDirectionalSpacing(); /** * Returns the margins for the view entry. */ Margins getMargins(ViewEntry entry, boolean isRoot); } private final ArrayList mViews = new ArrayList<>(); private final Parent mParent; /** * Creates a new {@link DirectionGroup} with the specified parent. */ DirectionGroup(Parent parent) { mParent = parent; } /** * Returns the head of the group. It is assumed that the order of the {@link ViewEntry} is * proactively maintained. */ public ViewEntry getHead() { return mViews.isEmpty() ? null : mViews.get(0); } /** * Adds a {@link ViewEntry} via {@link ViewEntry.Builder} to this group. */ public ViewEntry add(ViewEntry.Builder entryBuilder) { final ViewEntry entry = entryBuilder.setParent(this).build(); mViews.add(entry); // After adding view, reverse sort collection. Collections.sort(mViews); Collections.reverse(mViews); mParent.onEntriesChanged(); return entry; } @Override public void removeEntry(ViewEntry entry) { // Sort is handled when the view is added, so should still be correct after removal. // However, the head may have been removed, which may affect the layout of views in // other DirectionGroups of the same PositionGroup. mViews.remove(entry); mParent.onEntriesChanged(); } @Override public Margins getMargins(ViewEntry entry, boolean isRoot) { int directionalSpacing = entry.getLayoutParams().getDirectionalSpacing( mParent.getDefaultDirectionalSpacing()); Margins margins = new Margins(); if (!isRoot) { switch (entry.getLayoutParams().getDirection()) { case ComplicationLayoutParams.DIRECTION_START: margins = new Margins(0, 0, directionalSpacing, 0); break; case ComplicationLayoutParams.DIRECTION_UP: margins = new Margins(0, 0, 0, directionalSpacing); break; case ComplicationLayoutParams.DIRECTION_END: margins = new Margins(directionalSpacing, 0, 0, 0); break; case ComplicationLayoutParams.DIRECTION_DOWN: margins = new Margins(0, directionalSpacing, 0, 0); break; } } return Margins.combine(mParent.getMargins(entry, isRoot), margins); } /** * Invoked by {@link Parent} to update the layout of all children {@link ViewEntry} with * the specified head. Note that the head might not be in this group and instead part of a * neighboring group. */ public void updateViews(View groupHead) { Iterator it = mViews.iterator(); while (it.hasNext()) { final ViewEntry viewEntry = it.next(); viewEntry.applyLayoutParams(groupHead); groupHead = viewEntry.getView(); } } private List getViews() { return mViews; } } private final ConstraintLayout mLayout; private final int mDefaultDirectionalSpacing; private final HashMap mEntries = new HashMap<>(); private final HashMap mPositions = new HashMap<>(); private final TouchInsetManager.TouchInsetSession mSession; private final int mFadeInDuration; private final int mFadeOutDuration; private final HashMap> mPositionDirectionMarginMapping; /** */ @Inject public ComplicationLayoutEngine(@Named(SCOPED_COMPLICATIONS_LAYOUT) ConstraintLayout layout, @Named(COMPLICATION_DIRECTIONAL_SPACING_DEFAULT) int defaultDirectionalSpacing, @Named(COMPLICATION_MARGIN_POSITION_START) int complicationMarginPositionStart, @Named(COMPLICATION_MARGIN_POSITION_TOP) int complicationMarginPositionTop, @Named(COMPLICATION_MARGIN_POSITION_END) int complicationMarginPositionEnd, @Named(COMPLICATION_MARGIN_POSITION_BOTTOM) int complicationMarginPositionBottom, TouchInsetManager.TouchInsetSession session, @Named(COMPLICATIONS_FADE_IN_DURATION) int fadeInDuration, @Named(COMPLICATIONS_FADE_OUT_DURATION) int fadeOutDuration) { mLayout = layout; mDefaultDirectionalSpacing = defaultDirectionalSpacing; mSession = session; mFadeInDuration = fadeInDuration; mFadeOutDuration = fadeOutDuration; mPositionDirectionMarginMapping = generatePositionDirectionalMarginsMapping( complicationMarginPositionStart, complicationMarginPositionTop, complicationMarginPositionEnd, complicationMarginPositionBottom); } private static HashMap> generatePositionDirectionalMarginsMapping(int complicationMarginPositionStart, int complicationMarginPositionTop, int complicationMarginPositionEnd, int complicationMarginPositionBottom) { HashMap> mapping = new HashMap<>(); final Margins startMargins = new Margins(complicationMarginPositionStart, 0, 0, 0); final Margins topMargins = new Margins(0, complicationMarginPositionTop, 0, 0); final Margins endMargins = new Margins(0, 0, complicationMarginPositionEnd, 0); final Margins bottomMargins = new Margins(0, 0, 0, complicationMarginPositionBottom); addToMapping(mapping, POSITION_START | POSITION_TOP, DIRECTION_END, topMargins); addToMapping(mapping, POSITION_START | POSITION_TOP, DIRECTION_DOWN, startMargins); addToMapping(mapping, POSITION_START | POSITION_BOTTOM, DIRECTION_END, bottomMargins); addToMapping(mapping, POSITION_START | POSITION_BOTTOM, DIRECTION_UP, startMargins); addToMapping(mapping, POSITION_END | POSITION_TOP, DIRECTION_START, topMargins); addToMapping(mapping, POSITION_END | POSITION_TOP, DIRECTION_DOWN, endMargins); addToMapping(mapping, POSITION_END | POSITION_BOTTOM, DIRECTION_START, bottomMargins); addToMapping(mapping, POSITION_END | POSITION_BOTTOM, DIRECTION_UP, endMargins); return mapping; } private static void addToMapping(HashMap> mapping, @Position int position, @Direction int direction, Margins margins) { if (!mapping.containsKey(position)) { mapping.put(position, new HashMap<>()); } mapping.get(position).put(direction, margins); } @Override public void setVisibility(int visibility) { if (visibility == View.VISIBLE) { CrossFadeHelper.fadeIn(mLayout, mFadeInDuration, /* delay= */ 0); } else { CrossFadeHelper.fadeOut( mLayout, mFadeOutDuration, /* delay= */ 0, /* endRunnable= */ null); } } /** * Adds a complication to this {@link ComplicationLayoutEngine}. * @param id A {@link ComplicationId} unique to this complication. If this matches a * complication within this {@link ComplicationViewModel}, the existing complication * will be removed. * @param view The {@link View} to be shown. * @param lp The {@link ComplicationLayoutParams} as expressed by the {@link Complication}. * These will be interpreted into the final applied parameters. * @param category The {@link Complication.Category} for the {@link Complication}. */ public void addComplication(ComplicationId id, View view, ComplicationLayoutParams lp, @Complication.Category int category) { Log.d(TAG, "@" + Integer.toHexString(this.hashCode()) + " addComplication: " + id); // If the complication is present, remove. if (mEntries.containsKey(id)) { removeComplication(id); } final ViewEntry.Builder entryBuilder = new ViewEntry.Builder(view, mSession, lp, category); // Add position group if doesn't already exist final int position = lp.getPosition(); if (!mPositions.containsKey(position)) { mPositions.put(position, new PositionGroup(mDefaultDirectionalSpacing, mPositionDirectionMarginMapping.get(lp.getPosition()))); } // Insert entry into group final ViewEntry entry = mPositions.get(position).add(entryBuilder); mEntries.put(id, entry); mLayout.addView(entry.getView()); } /** * Removes a complication by {@link ComplicationId}. */ public boolean removeComplication(ComplicationId id) { final ViewEntry entry = mEntries.remove(id); if (entry == null) { Log.e(TAG, "could not find id:" + id); return false; } entry.remove(); return true; } /** * Gets an unordered list of all the views at a particular position. */ public List getViewsAtPosition(@Position int position) { return mPositions.entrySet().stream() .filter(entry -> (entry.getKey() & position) == position) .flatMap(entry -> entry.getValue().getViews().stream()) .map(ViewEntry::getView) .collect(Collectors.toList()); } }