blob: 41724ef62683cebdae07997438b54cc997a9896f [file] [log] [blame]
/*
* Copyright (C) 2014 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.qs;
import static com.android.systemui.util.Utils.useQsMediaPlayer;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.ArrayMap;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.LinearLayout;
import androidx.annotation.VisibleForTesting;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.widget.RemeasuringLinearLayout;
import com.android.systemui.R;
import com.android.systemui.plugins.qs.QSTile;
import com.android.systemui.settings.brightness.BrightnessSliderController;
import com.android.systemui.tuner.TunerService;
import com.android.systemui.tuner.TunerService.Tunable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** View that represents the quick settings tile panel (when expanded/pulled down). **/
public class QSPanel extends LinearLayout implements Tunable {
public static final String QS_SHOW_BRIGHTNESS = "qs_show_brightness";
public static final String QS_SHOW_HEADER = "qs_show_header";
private static final String TAG = "QSPanel";
protected final Context mContext;
private final int mMediaTopMargin;
private final int mMediaTotalBottomMargin;
private Runnable mCollapseExpandAction;
/**
* The index where the content starts that needs to be moved between parents
*/
private int mMovableContentStartIndex;
@Nullable
protected View mBrightnessView;
@Nullable
protected BrightnessSliderController mToggleSliderController;
/** Whether or not the QS media player feature is enabled. */
protected boolean mUsingMediaPlayer;
protected boolean mExpanded;
protected boolean mListening;
@Nullable protected QSTileHost mHost;
private final List<OnConfigurationChangedListener> mOnConfigurationChangedListeners =
new ArrayList<>();
@Nullable
protected View mFooter;
@Nullable
private PageIndicator mFooterPageIndicator;
private int mContentMarginStart;
private int mContentMarginEnd;
private boolean mUsingHorizontalLayout;
@Nullable
private LinearLayout mHorizontalLinearLayout;
@Nullable
protected LinearLayout mHorizontalContentContainer;
@Nullable
protected QSTileLayout mTileLayout;
private float mSquishinessFraction = 1f;
private final ArrayMap<View, Integer> mChildrenLayoutTop = new ArrayMap<>();
private final Rect mClippingRect = new Rect();
private ViewGroup mMediaHostView;
private boolean mShouldMoveMediaOnExpansion = true;
public QSPanel(Context context, AttributeSet attrs) {
super(context, attrs);
mUsingMediaPlayer = useQsMediaPlayer(context);
mMediaTotalBottomMargin = getResources().getDimensionPixelSize(
R.dimen.quick_settings_bottom_margin_media);
mMediaTopMargin = getResources().getDimensionPixelSize(
R.dimen.qs_tile_margin_vertical);
mContext = context;
setOrientation(VERTICAL);
mMovableContentStartIndex = getChildCount();
}
void initialize() {
mTileLayout = getOrCreateTileLayout();
if (mUsingMediaPlayer) {
mHorizontalLinearLayout = new RemeasuringLinearLayout(mContext);
mHorizontalLinearLayout.setOrientation(LinearLayout.HORIZONTAL);
mHorizontalLinearLayout.setClipChildren(false);
mHorizontalLinearLayout.setClipToPadding(false);
mHorizontalContentContainer = new RemeasuringLinearLayout(mContext);
mHorizontalContentContainer.setOrientation(LinearLayout.VERTICAL);
setHorizontalContentContainerClipping();
LayoutParams lp = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1);
int marginSize = (int) mContext.getResources().getDimension(R.dimen.qs_media_padding);
lp.setMarginStart(0);
lp.setMarginEnd(marginSize);
lp.gravity = Gravity.CENTER_VERTICAL;
mHorizontalLinearLayout.addView(mHorizontalContentContainer, lp);
lp = new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1);
addView(mHorizontalLinearLayout, lp);
}
}
protected void setHorizontalContentContainerClipping() {
mHorizontalContentContainer.setClipChildren(true);
mHorizontalContentContainer.setClipToPadding(false);
// Don't clip on the top, that way, secondary pages tiles can animate up
// Clipping coordinates should be relative to this view, not absolute (parent coordinates)
mHorizontalContentContainer.addOnLayoutChangeListener(
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if ((right - left) != (oldRight - oldLeft)
|| ((bottom - top) != (oldBottom - oldTop))) {
mClippingRect.right = right - left;
mClippingRect.bottom = bottom - top;
mHorizontalContentContainer.setClipBounds(mClippingRect);
}
});
mClippingRect.left = 0;
mClippingRect.top = -1000;
mHorizontalContentContainer.setClipBounds(mClippingRect);
}
/**
* Add brightness view above the tile layout.
*
* Used to add the brightness slider after construction.
*/
public void setBrightnessView(@NonNull View view) {
if (mBrightnessView != null) {
removeView(mBrightnessView);
mMovableContentStartIndex--;
}
addView(view, 0);
mBrightnessView = view;
setBrightnessViewMargin();
mMovableContentStartIndex++;
}
private void setBrightnessViewMargin() {
if (mBrightnessView != null) {
MarginLayoutParams lp = (MarginLayoutParams) mBrightnessView.getLayoutParams();
lp.topMargin = mContext.getResources()
.getDimensionPixelSize(R.dimen.qs_brightness_margin_top);
lp.bottomMargin = mContext.getResources()
.getDimensionPixelSize(R.dimen.qs_brightness_margin_bottom);
mBrightnessView.setLayoutParams(lp);
}
}
/** */
public QSTileLayout getOrCreateTileLayout() {
if (mTileLayout == null) {
mTileLayout = (QSTileLayout) LayoutInflater.from(mContext)
.inflate(R.layout.qs_paged_tile_layout, this, false);
mTileLayout.setSquishinessFraction(mSquishinessFraction);
}
return mTileLayout;
}
public void setSquishinessFraction(float squishinessFraction) {
if (Float.compare(squishinessFraction, mSquishinessFraction) == 0) {
return;
}
mSquishinessFraction = squishinessFraction;
if (mTileLayout == null) {
return;
}
mTileLayout.setSquishinessFraction(squishinessFraction);
if (getMeasuredWidth() == 0) {
return;
}
updateViewPositions();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mTileLayout instanceof PagedTileLayout) {
// Since PageIndicator gets measured before PagedTileLayout, we preemptively set the
// # of pages before the measurement pass so PageIndicator is measured appropriately
if (mFooterPageIndicator != null) {
mFooterPageIndicator.setNumPages(((PagedTileLayout) mTileLayout).getNumPages());
}
// In landscape, mTileLayout's parent is not the panel but a view that contains the
// tile layout and the media controls.
if (((View) mTileLayout).getParent() == this) {
// Allow the UI to be as big as it want's to, we're in a scroll view
int newHeight = 10000;
int availableHeight = MeasureSpec.getSize(heightMeasureSpec);
int excessHeight = newHeight - availableHeight;
// Measure with EXACTLY. That way, The content will only use excess height and will
// be measured last, after other views and padding is accounted for. This only
// works because our Layouts in here remeasure themselves with the exact content
// height.
heightMeasureSpec = MeasureSpec.makeMeasureSpec(newHeight, MeasureSpec.EXACTLY);
((PagedTileLayout) mTileLayout).setExcessHeight(excessHeight);
}
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// We want all the logic of LinearLayout#onMeasure, and for it to assign the excess space
// not used by the other children to PagedTileLayout. However, in this case, LinearLayout
// assumes that PagedTileLayout would use all the excess space. This is not the case as
// PagedTileLayout height is quantized (because it shows a certain number of rows).
// Therefore, after everything is measured, we need to make sure that we add up the correct
// total height
int height = getPaddingBottom() + getPaddingTop();
int numChildren = getChildCount();
for (int i = 0; i < numChildren; i++) {
View child = getChildAt(i);
if (child.getVisibility() != View.GONE) {
height += child.getMeasuredHeight();
MarginLayoutParams layoutParams = (MarginLayoutParams) child.getLayoutParams();
height += layoutParams.topMargin + layoutParams.bottomMargin;
}
}
setMeasuredDimension(getMeasuredWidth(), height);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
mChildrenLayoutTop.put(child, child.getTop());
}
updateViewPositions();
}
private void updateViewPositions() {
// Adjust view positions based on tile squishing
int tileHeightOffset = mTileLayout.getTilesHeight() - mTileLayout.getHeight();
boolean move = false;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (move) {
int topOffset;
if (child == mMediaHostView && !mShouldMoveMediaOnExpansion) {
topOffset = 0;
} else {
topOffset = tileHeightOffset;
}
int top = Objects.requireNonNull(mChildrenLayoutTop.get(child));
child.setLeftTopRightBottom(child.getLeft(), top + topOffset,
child.getRight(), top + topOffset + child.getHeight());
}
if (child == mTileLayout) {
move = true;
}
}
}
protected String getDumpableTag() {
return TAG;
}
@Override
public void onTuningChanged(String key, String newValue) {
if (QS_SHOW_BRIGHTNESS.equals(key) && mBrightnessView != null) {
updateViewVisibilityForTuningValue(mBrightnessView, newValue);
}
}
private void updateViewVisibilityForTuningValue(View view, @Nullable String newValue) {
view.setVisibility(TunerService.parseIntegerSwitch(newValue, true) ? VISIBLE : GONE);
}
@Nullable
View getBrightnessView() {
return mBrightnessView;
}
/**
* Links the footer's page indicator, which is used in landscape orientation to save space.
*
* @param pageIndicator indicator to use for page scrolling
*/
public void setFooterPageIndicator(PageIndicator pageIndicator) {
if (mTileLayout instanceof PagedTileLayout) {
mFooterPageIndicator = pageIndicator;
updatePageIndicator();
}
}
private void updatePageIndicator() {
if (mTileLayout instanceof PagedTileLayout) {
if (mFooterPageIndicator != null) {
mFooterPageIndicator.setVisibility(View.GONE);
((PagedTileLayout) mTileLayout).setPageIndicator(mFooterPageIndicator);
}
}
}
@Nullable
public QSTileHost getHost() {
return mHost;
}
public void updateResources() {
updatePadding();
updatePageIndicator();
setBrightnessViewMargin();
if (mTileLayout != null) {
mTileLayout.updateResources();
}
}
protected void updatePadding() {
final Resources res = mContext.getResources();
int paddingTop = res.getDimensionPixelSize(R.dimen.qs_panel_padding_top);
// Bottom padding only when there's a new footer with its height.
setPaddingRelative(getPaddingStart(),
paddingTop,
getPaddingEnd(),
getPaddingBottom());
}
void addOnConfigurationChangedListener(OnConfigurationChangedListener listener) {
mOnConfigurationChangedListeners.add(listener);
}
void removeOnConfigurationChangedListener(OnConfigurationChangedListener listener) {
mOnConfigurationChangedListeners.remove(listener);
}
@Override
protected void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
mOnConfigurationChangedListeners.forEach(
listener -> listener.onConfigurationChange(newConfig));
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mFooter = findViewById(R.id.qs_footer);
}
private void updateHorizontalLinearLayoutMargins() {
if (mHorizontalLinearLayout != null && !displayMediaMarginsOnMedia()) {
LayoutParams lp = (LayoutParams) mHorizontalLinearLayout.getLayoutParams();
lp.bottomMargin = Math.max(mMediaTotalBottomMargin - getPaddingBottom(), 0);
mHorizontalLinearLayout.setLayoutParams(lp);
}
}
/**
* @return true if the margin bottom of the media view should be on the media host or false
* if they should be on the HorizontalLinearLayout. Returning {@code false} is useful
* to visually center the tiles in the Media view, which doesn't work when the
* expanded panel actually scrolls.
*/
protected boolean displayMediaMarginsOnMedia() {
return true;
}
/**
* @return true if the media view needs margin on the top to separate it from the qs tiles
*/
protected boolean mediaNeedsTopMargin() {
return false;
}
private boolean needsDynamicRowsAndColumns() {
return true;
}
private void switchAllContentToParent(ViewGroup parent, QSTileLayout newLayout) {
int index = parent == this ? mMovableContentStartIndex : 0;
// Let's first move the tileLayout to the new parent, since that should come first.
switchToParent((View) newLayout, parent, index);
index++;
if (mFooter != null) {
// Then the footer with the settings
switchToParent(mFooter, parent, index);
index++;
}
}
private void switchToParent(View child, ViewGroup parent, int index) {
switchToParent(child, parent, index, getDumpableTag());
}
/** Call when orientation has changed and MediaHost needs to be adjusted. */
private void reAttachMediaHost(ViewGroup hostView, boolean horizontal) {
if (!mUsingMediaPlayer) {
return;
}
mMediaHostView = hostView;
ViewGroup newParent = horizontal ? mHorizontalLinearLayout : this;
ViewGroup currentParent = (ViewGroup) hostView.getParent();
if (currentParent != newParent) {
if (currentParent != null) {
currentParent.removeView(hostView);
}
newParent.addView(hostView);
LinearLayout.LayoutParams layoutParams = (LayoutParams) hostView.getLayoutParams();
layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
layoutParams.width = horizontal ? 0 : ViewGroup.LayoutParams.MATCH_PARENT;
layoutParams.weight = horizontal ? 1f : 0;
// Add any bottom margin, such that the total spacing is correct. This is only
// necessary if the view isn't horizontal, since otherwise the padding is
// carried in the parent of this view (to ensure correct vertical alignment)
layoutParams.bottomMargin = !horizontal || displayMediaMarginsOnMedia()
? Math.max(mMediaTotalBottomMargin - getPaddingBottom(), 0) : 0;
layoutParams.topMargin = mediaNeedsTopMargin() && !horizontal
? mMediaTopMargin : 0;
}
}
public void setExpanded(boolean expanded) {
if (mExpanded == expanded) return;
mExpanded = expanded;
if (!mExpanded && mTileLayout instanceof PagedTileLayout) {
((PagedTileLayout) mTileLayout).setCurrentItem(0, false);
}
}
public void setPageListener(final PagedTileLayout.PageListener pageListener) {
if (mTileLayout instanceof PagedTileLayout) {
((PagedTileLayout) mTileLayout).setPageListener(pageListener);
}
}
public boolean isExpanded() {
return mExpanded;
}
/** */
public void setListening(boolean listening) {
mListening = listening;
}
protected void drawTile(QSPanelControllerBase.TileRecord r, QSTile.State state) {
r.tileView.onStateChanged(state);
}
protected QSEvent openPanelEvent() {
return QSEvent.QS_PANEL_EXPANDED;
}
protected QSEvent closePanelEvent() {
return QSEvent.QS_PANEL_COLLAPSED;
}
protected QSEvent tileVisibleEvent() {
return QSEvent.QS_TILE_VISIBLE;
}
protected boolean shouldShowDetail() {
return mExpanded;
}
void addTile(QSPanelControllerBase.TileRecord tileRecord) {
final QSTile.Callback callback = new QSTile.Callback() {
@Override
public void onStateChanged(QSTile.State state) {
drawTile(tileRecord, state);
}
};
tileRecord.tile.addCallback(callback);
tileRecord.callback = callback;
tileRecord.tileView.init(tileRecord.tile);
tileRecord.tile.refreshState();
if (mTileLayout != null) {
mTileLayout.addTile(tileRecord);
}
}
void removeTile(QSPanelControllerBase.TileRecord tileRecord) {
mTileLayout.removeTile(tileRecord);
}
public int getGridHeight() {
return getMeasuredHeight();
}
@Nullable
QSTileLayout getTileLayout() {
return mTileLayout;
}
/** */
public void setContentMargins(int startMargin, int endMargin, ViewGroup mediaHostView) {
// Only some views actually want this content padding, others want to go all the way
// to the edge like the brightness slider
mContentMarginStart = startMargin;
mContentMarginEnd = endMargin;
updateMediaHostContentMargins(mediaHostView);
}
/**
* Update the margins of the media hosts
*/
protected void updateMediaHostContentMargins(ViewGroup mediaHostView) {
if (mUsingMediaPlayer) {
int marginStart = 0;
int marginEnd = 0;
if (mUsingHorizontalLayout) {
marginEnd = mContentMarginEnd;
}
updateMargins(mediaHostView, marginStart, marginEnd);
}
}
/**
* Update the margins of a view.
*
* @param view the view to adjust
* @param start the start margin to set
* @param end the end margin to set
*/
protected void updateMargins(View view, int start, int end) {
LayoutParams lp = (LayoutParams) view.getLayoutParams();
if (lp != null) {
lp.setMarginStart(start);
lp.setMarginEnd(end);
view.setLayoutParams(lp);
}
}
public boolean isListening() {
return mListening;
}
protected void setPageMargin(int pageMargin) {
if (mTileLayout instanceof PagedTileLayout) {
((PagedTileLayout) mTileLayout).setPageMargin(pageMargin);
}
}
void setUsingHorizontalLayout(boolean horizontal, ViewGroup mediaHostView, boolean force) {
if (horizontal != mUsingHorizontalLayout || force) {
mUsingHorizontalLayout = horizontal;
ViewGroup newParent = horizontal ? mHorizontalContentContainer : this;
switchAllContentToParent(newParent, mTileLayout);
reAttachMediaHost(mediaHostView, horizontal);
if (needsDynamicRowsAndColumns()) {
mTileLayout.setMinRows(horizontal ? 2 : 1);
mTileLayout.setMaxColumns(horizontal ? 2 : 4);
}
updateMargins(mediaHostView);
mHorizontalLinearLayout.setVisibility(horizontal ? View.VISIBLE : View.GONE);
}
}
private void updateMargins(ViewGroup mediaHostView) {
updateMediaHostContentMargins(mediaHostView);
updateHorizontalLinearLayoutMargins();
updatePadding();
}
/**
* Sets whether the media container should move during the expansion of the QS Panel.
*
* As the QS Panel expands and the QS unsquish, the views below the QS tiles move to adapt to
* the new height of the QS tiles.
*
* In some cases this might not be wanted for media. One example is when there is a transition
* animation of the media container happening on split shade lock screen.
*/
public void setShouldMoveMediaOnExpansion(boolean shouldMoveMediaOnExpansion) {
mShouldMoveMediaOnExpansion = shouldMoveMediaOnExpansion;
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
}
@Override
public boolean performAccessibilityAction(int action, Bundle arguments) {
if (action == AccessibilityNodeInfo.ACTION_EXPAND
|| action == AccessibilityNodeInfo.ACTION_COLLAPSE) {
if (mCollapseExpandAction != null) {
mCollapseExpandAction.run();
return true;
}
}
return super.performAccessibilityAction(action, arguments);
}
public void setCollapseExpandAction(Runnable action) {
mCollapseExpandAction = action;
}
private class H extends Handler {
private static final int ANNOUNCE_FOR_ACCESSIBILITY = 1;
@Override
public void handleMessage(Message msg) {
if (msg.what == ANNOUNCE_FOR_ACCESSIBILITY) {
announceForAccessibility((CharSequence) msg.obj);
}
}
}
public interface QSTileLayout {
/** */
default void saveInstanceState(Bundle outState) {}
/** */
default void restoreInstanceState(Bundle savedInstanceState) {}
/** */
void addTile(QSPanelControllerBase.TileRecord tile);
/** */
void removeTile(QSPanelControllerBase.TileRecord tile);
/** */
int getOffsetTop(QSPanelControllerBase.TileRecord tile);
/** */
boolean updateResources();
/** */
void setListening(boolean listening, UiEventLogger uiEventLogger);
/** */
int getHeight();
/** */
int getTilesHeight();
/**
* Sets a size modifier for the tile. Where 0 means collapsed, and 1 expanded.
*/
void setSquishinessFraction(float squishinessFraction);
/**
* Sets the minimum number of rows to show
*
* @param minRows the minimum.
*/
default boolean setMinRows(int minRows) {
return false;
}
/**
* Sets the max number of columns to show
*
* @param maxColumns the maximum
*
* @return true if the number of visible columns has changed.
*/
default boolean setMaxColumns(int maxColumns) {
return false;
}
/**
* Sets the expansion value and proposedTranslation to panel.
*/
default void setExpansion(float expansion, float proposedTranslation) {}
int getNumVisibleTiles();
}
interface OnConfigurationChangedListener {
void onConfigurationChange(Configuration newConfig);
}
@VisibleForTesting
static void switchToParent(View child, ViewGroup parent, int index, String tag) {
if (parent == null) {
Log.w(tag, "Trying to move view to null parent",
new IllegalStateException());
return;
}
ViewGroup currentParent = (ViewGroup) child.getParent();
if (currentParent != parent) {
if (currentParent != null) {
currentParent.removeView(child);
}
parent.addView(child, index);
return;
}
// Same parent, we are just changing indices
int currentIndex = parent.indexOfChild(child);
if (currentIndex == index) {
// We want to be in the same place. Nothing to do here
return;
}
parent.removeView(child);
parent.addView(child, index);
}
}