blob: 9cd149b9bfee81944b0fa566972e9a6e674f3c97 [file] [log] [blame]
/*
* 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());
}
}