diff options
| author | 2024-02-01 20:42:21 +0000 | |
|---|---|---|
| committer | 2024-02-01 20:42:21 +0000 | |
| commit | 2fc6057b387e5b406dc253cad51cf8c1157f3925 (patch) | |
| tree | 02b26761938748d43095ebaac27b180ebe22f2a6 | |
| parent | f7476a1458e3fb6709dbe266ca4f09bbe412077d (diff) | |
| parent | 4c7ac16f4d28e65cd01b674fd363c756d0e7d5da (diff) | |
Merge changes from topic "new-callstyle-action-layout" into main
* changes:
CallStyle: Center icon with label on action buttons
CallStyle: Evenly divide space for action buttons
CallStyle: Add booleans to control new action layout
3 files changed, 488 insertions, 14 deletions
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index d705eeb706e8..88839071bf6c 100644 --- a/core/java/android/app/Notification.java +++ b/core/java/android/app/Notification.java @@ -5946,6 +5946,12 @@ public class Notification implements Parcelable // there is enough space to do so (and fall back to the left edge if not). big.setInt(R.id.actions, "setCollapsibleIndentDimen", R.dimen.call_notification_collapsible_indent); + if (CallStyle.USE_NEW_ACTION_LAYOUT) { + if (CallStyle.DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "setting evenly divided mode on action list"); + } + big.setBoolean(R.id.actions, "setEvenlyDividedMode", true); + } } big.setBoolean(R.id.actions, "setEmphasizedMode", emphasizedMode); if (numActions > 0 && !p.mHideActions) { @@ -6421,7 +6427,15 @@ public class Notification implements Parcelable // Remove full-length color spans and ensure text contrast with the button fill. title = ContrastColorUtil.ensureColorSpanContrast(title, buttonFillColor); } - button.setTextViewText(R.id.action0, ensureColorSpanContrast(title, p)); + final CharSequence label = ensureColorSpanContrast(title, p); + if (p.mCallStyleActions && CallStyle.USE_NEW_ACTION_LAYOUT) { + if (CallStyle.DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "new action layout enabled, gluing instead of setting text"); + } + button.setCharSequence(R.id.action0, "glueLabel", label); + } else { + button.setTextViewText(R.id.action0, label); + } int textColor = ContrastColorUtil.resolvePrimaryColor(mContext, buttonFillColor, mInNightMode); if (tombstone) { @@ -6438,7 +6452,14 @@ public class Notification implements Parcelable button.setColorStateList(R.id.action0, "setButtonBackground", ColorStateList.valueOf(buttonFillColor)); if (p.mCallStyleActions) { - button.setImageViewIcon(R.id.action0, action.getIcon()); + if (CallStyle.USE_NEW_ACTION_LAYOUT) { + if (CallStyle.DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "new action layout enabled, gluing instead of setting icon"); + } + button.setIcon(R.id.action0, "glueIcon", action.getIcon()); + } else { + button.setImageViewIcon(R.id.action0, action.getIcon()); + } boolean priority = action.getExtras().getBoolean(CallStyle.KEY_ACTION_PRIORITY); button.setBoolean(R.id.action0, "setIsPriority", priority); int minWidthDimen = @@ -9565,6 +9586,15 @@ public class Notification implements Parcelable * </pre> */ public static class CallStyle extends Style { + /** + * @hide + */ + public static final boolean USE_NEW_ACTION_LAYOUT = false; + + /** + * @hide + */ + public static final boolean DEBUG_NEW_ACTION_LAYOUT = true; /** * @hide diff --git a/core/java/com/android/internal/widget/EmphasizedNotificationButton.java b/core/java/com/android/internal/widget/EmphasizedNotificationButton.java index ce6af49eef0b..5cda3f2b2bc0 100644 --- a/core/java/com/android/internal/widget/EmphasizedNotificationButton.java +++ b/core/java/com/android/internal/widget/EmphasizedNotificationButton.java @@ -16,16 +16,30 @@ package com.android.internal.widget; +import static android.app.Notification.CallStyle.DEBUG_NEW_ACTION_LAYOUT; +import static android.app.Notification.CallStyle.USE_NEW_ACTION_LAYOUT; +import static android.text.style.DynamicDrawableSpan.ALIGN_CENTER; + +import android.annotation.NonNull; import android.annotation.Nullable; import android.content.Context; import android.content.res.ColorStateList; +import android.content.res.TypedArray; import android.graphics.BlendMode; +import android.graphics.Canvas; +import android.graphics.Paint; import android.graphics.drawable.Drawable; import android.graphics.drawable.DrawableWrapper; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.Icon; import android.graphics.drawable.RippleDrawable; +import android.text.SpannableStringBuilder; +import android.text.TextPaint; +import android.text.style.ImageSpan; +import android.text.style.MetricAffectingSpan; +import android.text.style.ReplacementSpan; import android.util.AttributeSet; +import android.util.Log; import android.view.RemotableViewMethod; import android.widget.Button; import android.widget.RemoteViews; @@ -43,6 +57,14 @@ public class EmphasizedNotificationButton extends Button { private final GradientDrawable mBackground; private boolean mPriority; + private int mInitialDrawablePadding; + private int mIconSize; + + private Drawable mIconToGlue; + private CharSequence mLabelToGlue; + private int mGluedLayoutDirection = LAYOUT_DIRECTION_UNDEFINED; + private boolean mGluePending; + public EmphasizedNotificationButton(Context context) { this(context, null); } @@ -58,10 +80,25 @@ public class EmphasizedNotificationButton extends Button { public EmphasizedNotificationButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); + mRipple = (RippleDrawable) getBackground(); mRipple.mutate(); DrawableWrapper inset = (DrawableWrapper) mRipple.getDrawable(0); mBackground = (GradientDrawable) inset.getDrawable(); + + mIconSize = mContext.getResources().getDimensionPixelSize( + R.dimen.notification_actions_icon_drawable_size); + + try (TypedArray typedArray = context.obtainStyledAttributes( + attrs, android.R.styleable.TextView, defStyleAttr, defStyleRes)) { + mInitialDrawablePadding = typedArray.getDimensionPixelSize( + android.R.styleable.TextView_drawablePadding, 0); + } + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "iconSize = " + mIconSize + "px, " + + "initialDrawablePadding = " + mInitialDrawablePadding + "px"); + } } @RemotableViewMethod @@ -95,19 +132,248 @@ public class EmphasizedNotificationButton extends Button { return () -> setImageDrawable(drawable); } - private void setImageDrawable(Drawable drawable) { + private void setImageDrawable(@Nullable Drawable drawable) { if (drawable != null) { - drawable.mutate(); - drawable.setTintList(getTextColors()); - drawable.setTintBlendMode(BlendMode.SRC_IN); - int iconSize = mContext.getResources().getDimensionPixelSize( - R.dimen.notification_actions_icon_drawable_size); - drawable.setBounds(0, 0, iconSize, iconSize); + prepareIcon(drawable); } setCompoundDrawablesRelative(drawable, null, null, null); } /** + * Sets an icon to be 'glued' to the label when this button is displayed, so the icon will stay + * with the text if the button is wider than needed and the text isn't start-aligned. + * + * As with {@link #setImageIcon(Icon)}, the Icon will have its size constrained and will be set + * to the same color as the text, and this must be called after {@link #setTextColor(int)} for + * the latter to work. + * + * This must be called along with {@link #glueLabel(CharSequence)}, in any order, before the + * button is displayed. + */ + @RemotableViewMethod(asyncImpl = "glueIconAsync") + public void glueIcon(@Nullable Icon icon) { + final Drawable drawable = icon == null ? null : icon.loadDrawable(mContext); + setIconToGlue(drawable); + } + + /** + * @hide + */ + @RemotableViewMethod + public Runnable glueIconAsync(@Nullable Icon icon) { + final Drawable drawable = icon == null ? null : icon.loadDrawable(mContext); + return () -> setIconToGlue(drawable); + } + + private void setIconToGlue(@Nullable Drawable icon) { + if (!USE_NEW_ACTION_LAYOUT) { + Log.e(TAG, "glueIcon: new action layout disabled; doing nothing"); + return; + } + + prepareIcon(icon); + + mIconToGlue = icon; + mGluePending = true; + + glueIconAndLabelIfNeeded(); + } + + private void prepareIcon(@NonNull Drawable drawable) { + drawable.mutate(); + drawable.setTintList(getTextColors()); + drawable.setTintBlendMode(BlendMode.SRC_IN); + drawable.setBounds(0, 0, mIconSize, mIconSize); + } + + /** + * Sets a label to be 'glued' to the icon when this button is displayed, so the icon will stay + * with the text if the button is wider than needed and the text isn't start-aligned. + * + * This must be called along with {@link #glueIcon(Icon)}, in any order, before the button is + * displayed. + */ + @RemotableViewMethod(asyncImpl = "glueLabelAsync") + public void glueLabel(@Nullable CharSequence label) { + setLabelToGlue(label); + } + + /** + * @hide + */ + @RemotableViewMethod + public Runnable glueLabelAsync(@Nullable CharSequence label) { + return () -> setLabelToGlue(label); + } + + private void setLabelToGlue(@Nullable CharSequence label) { + if (!USE_NEW_ACTION_LAYOUT) { + Log.e(TAG, "glueLabel: new action layout disabled; doing nothing"); + return; + } + + mLabelToGlue = label; + mGluePending = true; + + glueIconAndLabelIfNeeded(); + } + + @Override + public void onRtlPropertiesChanged(int layoutDirection) { + super.onRtlPropertiesChanged(layoutDirection); + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "onRtlPropertiesChanged: layoutDirection = " + layoutDirection + ", " + + "gluedLayoutDirection = " + mGluedLayoutDirection); + } + + if (layoutDirection != mGluedLayoutDirection) { + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "onRtlPropertiesChanged: layout direction changed; regluing"); + } + mGluePending = true; + } + + glueIconAndLabelIfNeeded(); + } + + private void glueIconAndLabelIfNeeded() { + // Don't need to glue: + + if (!mGluePending) { + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "glueIconAndLabelIfNeeded: glue not pending; doing nothing"); + } + return; + } + + if (mIconToGlue == null && mLabelToGlue == null) { + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "glueIconAndLabelIfNeeded: no icon or label to glue; doing nothing"); + } + mGluePending = false; + return; + } + + if (!USE_NEW_ACTION_LAYOUT) { + Log.e(TAG, "glueIconAndLabelIfNeeded: new action layout disabled; doing nothing"); + return; + } + + // Not ready to glue yet: + + if (!isLayoutDirectionResolved()) { + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "glueIconAndLabelIfNeeded: " + + "layout direction not resolved; doing nothing"); + } + return; + } + + // Ready to glue but don't have an icon *and* a label: + // + // (Note that this will *not* happen while the button is being initialized, since we won't + // be ready to glue. This can only happen if the button is initialized and displayed and + // *then* someone calls glueIcon or glueLabel. + + if (mIconToGlue == null) { + Log.w(TAG, "glueIconAndLabelIfNeeded: label glued without icon; doing nothing"); + return; + } + + if (mLabelToGlue == null) { + Log.w(TAG, "glueIconAndLabelIfNeeded: icon glued without label; doing nothing"); + return; + } + + // Can't glue: + + final int layoutDirection = getLayoutDirection(); + if (layoutDirection != LAYOUT_DIRECTION_LTR && layoutDirection != LAYOUT_DIRECTION_RTL) { + Log.e(TAG, "glueIconAndLabelIfNeeded: " + + "resolved layout direction neither LTR nor RTL; " + + "doing nothing"); + return; + } + + // No excuses left, let's glue it! + + glueIconAndLabel(layoutDirection); + + mGluePending = false; + mGluedLayoutDirection = layoutDirection; + } + + // Unicode replacement character + private static final String IMAGE_SPAN_TEXT = "\ufffd"; + + // Unicode no-break space + private static final String SPACER_SPAN_TEXT = "\u00a0"; + + private static final String LEFT_TO_RIGHT_ISOLATE = "\u2066"; + private static final String RIGHT_TO_LEFT_ISOLATE = "\u2067"; + private static final String FIRST_STRONG_ISOLATE = "\u2068"; + private static final String POP_DIRECTIONAL_ISOLATE = "\u2069"; + + private void glueIconAndLabel(int layoutDirection) { + final boolean rtlLayout = layoutDirection == LAYOUT_DIRECTION_RTL; + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "glueIconAndLabel: " + + "icon = " + mIconToGlue + ", " + + "iconSize = " + mIconSize + "px, " + + "initialDrawablePadding = " + mInitialDrawablePadding + "px, " + + "labelToGlue.length = " + mLabelToGlue.length() + ", " + + "rtlLayout = " + rtlLayout); + } + + logIfTextDirectionNotFirstStrong(); + + final SpannableStringBuilder builder = new SpannableStringBuilder(); + + // The text direction of the label might not match the layout direction of the button, so + // wrap the entire string in a LEFT-TO-RIGHT ISOLATE or RIGHT-TO-LEFT ISOLATE to match the + // layout direction. This puts the icon, padding, and label in the right order. + builder.append(rtlLayout ? RIGHT_TO_LEFT_ISOLATE : LEFT_TO_RIGHT_ISOLATE); + + appendSpan(builder, IMAGE_SPAN_TEXT, new ImageSpan(mIconToGlue, ALIGN_CENTER)); + appendSpan(builder, SPACER_SPAN_TEXT, new SpacerSpan(mInitialDrawablePadding)); + + // If the text and layout directions are different, we would end up with the *label* in the + // wrong direction, so wrap the label in a FIRST STRONG ISOLATE. This triggers the same + // automatic text direction heuristic that Android uses by default. + builder.append(FIRST_STRONG_ISOLATE); + + appendSpan(builder, mLabelToGlue, new CenterBesideImageSpan(mIconSize)); + + builder.append(POP_DIRECTIONAL_ISOLATE); + builder.append(POP_DIRECTIONAL_ISOLATE); + + setText(builder); + } + + private void logIfTextDirectionNotFirstStrong() { + if (!isTextDirectionResolved()) { + Log.e(TAG, "glueIconAndLabel: text direction not resolved; " + + "letting View assume FIRST STRONG"); + } + final int textDirection = getTextDirection(); + if (textDirection != TEXT_DIRECTION_FIRST_STRONG) { + Log.w(TAG, "glueIconAndLabel: " + + "expected text direction TEXT_DIRECTION_FIRST_STRONG " + + "but found " + textDirection + "; " + + "will use a FIRST STRONG ISOLATE regardless"); + } + } + + private void appendSpan(SpannableStringBuilder builder, CharSequence text, Object span) { + final int spanStart = builder.length(); + builder.append(text); + final int spanEnd = builder.length(); + builder.setSpan(span, spanStart, spanEnd, 0); + } + + /** * Sets whether this view is a priority over its peers (which affects width). * Specifically, this is used by {@link NotificationActionListLayout} to give this view width * priority ahead of user-defined buttons when allocating horizontal space. @@ -123,4 +389,104 @@ public class EmphasizedNotificationButton extends Button { public boolean isPriority() { return mPriority; } + + private static class SpacerSpan extends ReplacementSpan { + private int mWidth; + + SpacerSpan(int width) { + mWidth = width; + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "width = " + mWidth + "px"); + } + } + + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, + @Nullable Paint.FontMetricsInt fontMetrics) { + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "getSize returning " + mWidth + "px"); + } + + return mWidth; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, + float x, int top, int y, int bottom, @NonNull Paint paint) { + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "drawing nothing"); + } + + // Draw nothing, it's a spacer. + } + + private static final String TAG = "SpacerSpan"; + } + + private static class CenterBesideImageSpan extends MetricAffectingSpan { + private int mImageHeight; + + private boolean mMeasured; + private int mBaselineShiftOffset; + + CenterBesideImageSpan(int imageHeight) { + mImageHeight = imageHeight; + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "imageHeight = " + mImageHeight + "px"); + } + } + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) { + final int textHeight = (int) -textPaint.ascent(); + + /* + * We only need to shift the text *up* if the text is shorter than the image; ImageSpan + * with ALIGN_CENTER will shift the *image* up if the text is taller than the image. + */ + if (textHeight < mImageHeight) { + mBaselineShiftOffset = -(mImageHeight - textHeight) / 2; + } else { + mBaselineShiftOffset = 0; + } + + mMeasured = true; + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.d(TAG, "updateMeasureState: " + + "imageHeight = " + mImageHeight + "px, " + + "textHeight = " + textHeight + "px, " + + "baselineShiftOffset = " + mBaselineShiftOffset + "px"); + } + + textPaint.baselineShift += mBaselineShiftOffset; + } + + @Override + public void updateDrawState(TextPaint textPaint) { + if (textPaint == null) { + Log.e(TAG, "updateDrawState: textPaint is null; doing nothing"); + return; + } + + if (!mMeasured) { + Log.e(TAG, "updateDrawState: called without measure; doing nothing"); + return; + } + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "updateDrawState: " + + "baselineShiftOffset = " + mBaselineShiftOffset + "px"); + } + + textPaint.baselineShift += mBaselineShiftOffset; + } + + private static final String TAG = "CenterBesideImageSpan"; + } + + private static final String TAG = "EmphasizedNotificationButton"; } diff --git a/core/java/com/android/internal/widget/NotificationActionListLayout.java b/core/java/com/android/internal/widget/NotificationActionListLayout.java index a7a69c9e43fb..69d254499ef4 100644 --- a/core/java/com/android/internal/widget/NotificationActionListLayout.java +++ b/core/java/com/android/internal/widget/NotificationActionListLayout.java @@ -16,12 +16,16 @@ package com.android.internal.widget; +import static android.app.Notification.CallStyle.DEBUG_NEW_ACTION_LAYOUT; +import static android.app.Notification.CallStyle.USE_NEW_ACTION_LAYOUT; + import android.annotation.DimenRes; import android.app.Notification; import android.content.Context; import android.content.res.TypedArray; import android.graphics.drawable.RippleDrawable; import android.util.AttributeSet; +import android.util.Log; import android.view.Gravity; import android.view.RemotableViewMethod; import android.view.View; @@ -41,13 +45,13 @@ import java.util.Comparator; */ @RemoteViews.RemoteView public class NotificationActionListLayout extends LinearLayout { - private final int mGravity; private int mTotalWidth = 0; private int mExtraStartPadding = 0; private ArrayList<TextViewInfo> mMeasureOrderTextViews = new ArrayList<>(); private ArrayList<View> mMeasureOrderOther = new ArrayList<>(); private boolean mEmphasizedMode; + private boolean mEvenlyDividedMode; private int mDefaultPaddingBottom; private int mDefaultPaddingTop; private int mEmphasizedPaddingTop; @@ -124,6 +128,42 @@ public class NotificationActionListLayout extends LinearLayout { } } + private int measureAndReturnEvenlyDividedWidth(int heightMeasureSpec, int innerWidth) { + final int numChildren = getChildCount(); + int childMarginSum = 0; + for (int i = 0; i < numChildren; i++) { + final View child = getChildAt(i); + if (child.getVisibility() != GONE) { + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + childMarginSum += lp.leftMargin + lp.rightMargin; + } + } + + final int innerWidthMinusChildMargins = innerWidth - childMarginSum; + final int childWidth = innerWidthMinusChildMargins / mNumNotGoneChildren; + final int childWidthMeasureSpec = + MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY); + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "measuring evenly divided width: " + + "numChildren = " + numChildren + ", " + + "innerWidth = " + innerWidth + "px, " + + "childMarginSum = " + childMarginSum + "px, " + + "innerWidthMinusChildMargins = " + innerWidthMinusChildMargins + "px, " + + "childWidth = " + childWidth + "px, " + + "childWidthMeasureSpec = " + MeasureSpec.toString(childWidthMeasureSpec)); + } + + for (int i = 0; i < numChildren; i++) { + final View child = getChildAt(i); + if (child.getVisibility() != GONE) { + child.measure(childWidthMeasureSpec, heightMeasureSpec); + } + } + + return innerWidth; + } + private int measureAndGetUsedWidth(int widthMeasureSpec, int heightMeasureSpec, int innerWidth, boolean collapsePriorityActions) { final int numChildren = getChildCount(); @@ -208,11 +248,16 @@ public class NotificationActionListLayout extends LinearLayout { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { countAndRebuildMeasureOrder(); final int innerWidth = MeasureSpec.getSize(widthMeasureSpec) - mPaddingLeft - mPaddingRight; - int usedWidth = measureAndGetUsedWidth(widthMeasureSpec, heightMeasureSpec, innerWidth, - false /* collapsePriorityButtons */); - if (mNumPriorityChildren != 0 && usedWidth >= innerWidth) { + int usedWidth; + if (mEvenlyDividedMode) { + usedWidth = measureAndReturnEvenlyDividedWidth(heightMeasureSpec, innerWidth); + } else { usedWidth = measureAndGetUsedWidth(widthMeasureSpec, heightMeasureSpec, innerWidth, - true /* collapsePriorityButtons */); + false /* collapsePriorityButtons */); + if (mNumPriorityChildren != 0 && usedWidth >= innerWidth) { + usedWidth = measureAndGetUsedWidth(widthMeasureSpec, heightMeasureSpec, innerWidth, + true /* collapsePriorityButtons */); + } } mTotalWidth = usedWidth + mPaddingRight + mPaddingLeft + mExtraStartPadding; @@ -352,6 +397,38 @@ public class NotificationActionListLayout extends LinearLayout { } /** + * Sets whether the available width should be distributed evenly among the action buttons. + * + * When enabled, the available width (after subtracting this layout's padding and all of the + * buttons' margins) is divided by the number of (not-GONE) buttons, and each button is forced + * to that exact width, even if it is less <em>or more</em> width than they need. + * + * When disabled, the available width is allocated as buttons need; if that exceeds the + * available width, priority buttons are collapsed to just their icon to save space. + * + * @param evenlyDividedMode whether to enable evenly divided mode + */ + @RemotableViewMethod + public void setEvenlyDividedMode(boolean evenlyDividedMode) { + if (evenlyDividedMode && !USE_NEW_ACTION_LAYOUT) { + Log.e(TAG, "setEvenlyDividedMode(true) called with new action layout disabled; " + + "leaving evenly divided mode disabled"); + return; + } + + if (evenlyDividedMode == mEvenlyDividedMode) { + return; + } + + if (DEBUG_NEW_ACTION_LAYOUT) { + Log.v(TAG, "evenlyDividedMode changed to " + evenlyDividedMode + "; " + + "requesting layout"); + } + mEvenlyDividedMode = evenlyDividedMode; + requestLayout(); + } + + /** * Set whether the list is in a mode where some actions are emphasized. This will trigger an * equal measuring where all actions are full height and change a few parameters like * the padding. @@ -410,4 +487,5 @@ public class NotificationActionListLayout extends LinearLayout { } } + private static final String TAG = "NotificationActionListLayout"; } |