| /* |
| * 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.dreams.complication; |
| |
| import static com.android.systemui.dreams.complication.dagger.ComplicationHostViewModule.COMPLICATIONS_FADE_IN_DURATION; |
| import static com.android.systemui.dreams.complication.dagger.ComplicationHostViewModule.COMPLICATIONS_FADE_OUT_DURATION; |
| import static com.android.systemui.dreams.complication.dagger.ComplicationHostViewModule.COMPLICATION_MARGIN; |
| import static com.android.systemui.dreams.complication.dagger.ComplicationHostViewModule.SCOPED_COMPLICATIONS_LAYOUT; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewPropertyAnimator; |
| |
| import androidx.constraintlayout.widget.ConstraintLayout; |
| import androidx.constraintlayout.widget.Constraints; |
| |
| import com.android.systemui.R; |
| import com.android.systemui.dreams.complication.ComplicationLayoutParams.Position; |
| import com.android.systemui.dreams.dagger.DreamOverlayComponent; |
| 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}. |
| */ |
| @DreamOverlayComponent.DreamOverlayScope |
| public class ComplicationLayoutEngine implements Complication.VisibilityController { |
| public static final String TAG = "ComplicationLayoutEng"; |
| |
| /** |
| * {@link ViewEntry} is an internal container, capturing information necessary for working with |
| * a particular {@link Complication} view. |
| */ |
| private static class ViewEntry implements Comparable<ViewEntry> { |
| private final View mView; |
| private final ComplicationLayoutParams mLayoutParams; |
| private final TouchInsetManager.TouchInsetSession mTouchInsetSession; |
| private final Parent mParent; |
| @Complication.Category |
| private final int mCategory; |
| private final int mMargin; |
| |
| /** |
| * 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, |
| int margin) { |
| 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; |
| mMargin = margin; |
| |
| 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; |
| } |
| |
| if (!isRoot) { |
| switch(direction) { |
| case ComplicationLayoutParams.DIRECTION_DOWN: |
| params.setMargins(0, mMargin, 0, 0); |
| break; |
| case ComplicationLayoutParams.DIRECTION_UP: |
| params.setMargins(0, 0, 0, mMargin); |
| break; |
| case ComplicationLayoutParams.DIRECTION_END: |
| params.setMarginStart(mMargin); |
| break; |
| case ComplicationLayoutParams.DIRECTION_START: |
| params.setMarginEnd(mMargin); |
| break; |
| } |
| } |
| }); |
| |
| mView.setLayoutParams(params); |
| } |
| |
| private void setGuide(ConstraintLayout.LayoutParams lp, int validDirections, |
| Consumer<ConstraintLayout.LayoutParams> 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; |
| private int mMargin; |
| |
| 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; |
| } |
| |
| /** |
| * Sets the margin that will be applied in the direction the complication is laid out |
| * towards. |
| */ |
| Builder setMargin(int margin) { |
| mMargin = margin; |
| return this; |
| } |
| |
| /** |
| * Builds and returns the resulting {@link ViewEntry}. |
| */ |
| ViewEntry build() { |
| return new ViewEntry(mView, mLayoutParams, mTouchSession, mCategory, mParent, |
| mMargin); |
| } |
| } |
| |
| /** |
| * An interface allowing an {@link ViewEntry} to signal events. |
| */ |
| interface Parent { |
| /** |
| * Indicates the {@link ViewEntry} requests removal. |
| */ |
| void removeEntry(ViewEntry entry); |
| } |
| } |
| |
| /** |
| * {@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<Integer, DirectionGroup> mDirectionGroups = new HashMap<>(); |
| |
| /** |
| * 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(); |
| } |
| |
| 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<ViewEntry> getViews() { |
| final ArrayList<ViewEntry> 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(); |
| } |
| private final ArrayList<ViewEntry> mViews = new ArrayList<>(); |
| private final Parent mParent; |
| |
| /** |
| * Creates a new {@link DirectionGroup} with the specified parent. Note that the |
| * {@link DirectionGroup} does not store its own direction. It is the responsibility of the |
| * {@link DirectionGroup.Parent} to maintain this association. |
| */ |
| 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(); |
| } |
| |
| /** |
| * 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<ViewEntry> it = mViews.iterator(); |
| |
| while (it.hasNext()) { |
| final ViewEntry viewEntry = it.next(); |
| viewEntry.applyLayoutParams(groupHead); |
| groupHead = viewEntry.getView(); |
| } |
| } |
| |
| private List<ViewEntry> getViews() { |
| return mViews; |
| } |
| } |
| |
| private final ConstraintLayout mLayout; |
| private final int mMargin; |
| private final HashMap<ComplicationId, ViewEntry> mEntries = new HashMap<>(); |
| private final HashMap<Integer, PositionGroup> mPositions = new HashMap<>(); |
| private final TouchInsetManager.TouchInsetSession mSession; |
| private final int mFadeInDuration; |
| private final int mFadeOutDuration; |
| private ViewPropertyAnimator mViewPropertyAnimator; |
| |
| /** */ |
| @Inject |
| public ComplicationLayoutEngine(@Named(SCOPED_COMPLICATIONS_LAYOUT) ConstraintLayout layout, |
| @Named(COMPLICATION_MARGIN) int margin, |
| TouchInsetManager.TouchInsetSession session, |
| @Named(COMPLICATIONS_FADE_IN_DURATION) int fadeInDuration, |
| @Named(COMPLICATIONS_FADE_OUT_DURATION) int fadeOutDuration) { |
| mLayout = layout; |
| mMargin = margin; |
| mSession = session; |
| mFadeInDuration = fadeInDuration; |
| mFadeOutDuration = fadeOutDuration; |
| } |
| |
| @Override |
| public void setVisibility(int visibility, boolean animate) { |
| final boolean appearing = visibility == View.VISIBLE; |
| |
| if (mViewPropertyAnimator != null) { |
| mViewPropertyAnimator.cancel(); |
| } |
| |
| if (appearing) { |
| mLayout.setVisibility(View.VISIBLE); |
| } |
| |
| mViewPropertyAnimator = mLayout.animate() |
| .alpha(appearing ? 1f : 0f) |
| .setDuration(appearing ? mFadeInDuration : mFadeOutDuration) |
| .setListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| mLayout.setVisibility(visibility); |
| } |
| }); |
| } |
| |
| /** |
| * 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) |
| .setMargin(mMargin); |
| |
| // Add position group if doesn't already exist |
| final int position = lp.getPosition(); |
| if (!mPositions.containsKey(position)) { |
| mPositions.put(position, new PositionGroup()); |
| } |
| |
| // 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<View> 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()); |
| } |
| } |