Custom SmartReplyView layout
Implement SmartReplyView#onMeasure and SmartReplyView#onLayout according
to the redlines in go/p-notifications:
* Smart reply buttons can have at most 2 lines of text.
* Squeeze buttons to 2 lines of text when necessary to fit more
buttons.
* Don't show buttons which have more than 2 lines of text or don't fit
within the notification width.
* Update button background and text color.
Screenshot: https://screenshot.googleplex.com/cSM6Ve7qjb3.png
Bug: 67765414
Test: atest SmartReplyViewTest
Change-Id: Ia161c7f5669e7aef1b8c3480a8836784f0bde055
diff --git a/packages/SystemUI/res/drawable/smart_reply_button_background.xml b/packages/SystemUI/res/drawable/smart_reply_button_background.xml
index 1cd1451..c5ac67b 100644
--- a/packages/SystemUI/res/drawable/smart_reply_button_background.xml
+++ b/packages/SystemUI/res/drawable/smart_reply_button_background.xml
@@ -20,7 +20,9 @@
android:color="@color/notification_ripple_untinted_color">
<item>
<shape android:shape="rectangle">
- <corners android:radius="@dimen/smart_reply_button_corner_radius"/>
+ <!-- Use non-zero corner radius to work around b/73285195. The actual corner radius is
+ set dynamically at runtime in SmartReplyView. -->
+ <corners android:radius="1dp"/>
<solid android:color="@color/smart_reply_button_background"/>
</shape>
</item>
diff --git a/packages/SystemUI/res/layout/smart_reply_button.xml b/packages/SystemUI/res/layout/smart_reply_button.xml
index 4ac41d5..3c6edcd 100644
--- a/packages/SystemUI/res/layout/smart_reply_button.xml
+++ b/packages/SystemUI/res/layout/smart_reply_button.xml
@@ -16,17 +16,19 @@
~ limitations under the License
-->
+<!-- android:paddingHorizontal is set dynamically in SmartReplyView. -->
<Button xmlns:android="http://schemas.android.com/apk/res/android"
style="@android:style/Widget.Material.Button.Borderless.Small"
android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/smart_reply_button_spacing"
+ android:layout_height="match_parent"
+ android:minWidth="0dp"
+ android:minHeight="@dimen/smart_reply_button_min_height"
android:paddingVertical="@dimen/smart_reply_button_padding_vertical"
- android:paddingHorizontal="@dimen/smart_reply_button_corner_radius"
android:background="@drawable/smart_reply_button_background"
android:gravity="center"
android:fontFamily="sans-serif"
android:textSize="@dimen/smart_reply_button_font_size"
+ android:lineSpacingExtra="@dimen/smart_reply_button_line_spacing_extra"
android:textColor="@color/smart_reply_button_text"
android:textStyle="normal"
- android:singleLine="true"/>
\ No newline at end of file
+ android:ellipsize="none"/>
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/smart_reply_view.xml b/packages/SystemUI/res/layout/smart_reply_view.xml
index 6d53386..6f21787 100644
--- a/packages/SystemUI/res/layout/smart_reply_view.xml
+++ b/packages/SystemUI/res/layout/smart_reply_view.xml
@@ -19,9 +19,12 @@
<!-- LinearLayout -->
<com.android.systemui.statusbar.policy.SmartReplyView
xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:systemui="http://schemas.android.com/apk/res-auto"
android:id="@+id/smart_reply_view"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
- android:layout_gravity="end">
+ systemui:spacing="@dimen/smart_reply_button_spacing"
+ systemui:singleLineButtonPaddingHorizontal="@dimen/smart_reply_button_padding_horizontal_single_line"
+ systemui:doubleLineButtonPaddingHorizontal="@dimen/smart_reply_button_padding_horizontal_double_line">
<!-- smart_reply_button(s) will be added here. -->
</com.android.systemui.statusbar.policy.SmartReplyView>
\ No newline at end of file
diff --git a/packages/SystemUI/res/values/attrs.xml b/packages/SystemUI/res/values/attrs.xml
index a923f0b..f0a5fe4 100644
--- a/packages/SystemUI/res/values/attrs.xml
+++ b/packages/SystemUI/res/values/attrs.xml
@@ -130,5 +130,11 @@
<attr name="darkIconTheme" format="reference" />
<attr name="wallpaperTextColor" format="reference|color" />
<attr name="wallpaperTextColorSecondary" format="reference|color" />
+
+ <declare-styleable name="SmartReplyView">
+ <attr name="spacing" format="dimension" />
+ <attr name="singleLineButtonPaddingHorizontal" format="dimension" />
+ <attr name="doubleLineButtonPaddingHorizontal" format="dimension" />
+ </declare-styleable>
</resources>
diff --git a/packages/SystemUI/res/values/colors.xml b/packages/SystemUI/res/values/colors.xml
index be8e990..c054d16 100644
--- a/packages/SystemUI/res/values/colors.xml
+++ b/packages/SystemUI/res/values/colors.xml
@@ -156,9 +156,8 @@
<color name="zen_introduction">#ffffffff</color>
-
- <color name="smart_reply_button_text">#ff4285f4</color><!-- blue 500 -->
- <color name="smart_reply_button_background">#fff7f7f7</color>
+ <color name="smart_reply_button_text">#de000000</color> <!-- 87% black -->
+ <color name="smart_reply_button_background">#fff2f2f2</color>
<!-- Fingerprint dialog colors -->
<color name="fingerprint_dialog_bg_color">#f4ffffff</color> <!-- 96% white -->
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index bc828ff..7519683 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -891,10 +891,13 @@
<dimen name="home_padding">16dp</dimen>
<!-- Smart reply button -->
- <dimen name="smart_reply_button_corner_radius">24dip</dimen>
<dimen name="smart_reply_button_spacing">8dp</dimen>
- <dimen name="smart_reply_button_padding_vertical">4dp</dimen>
+ <dimen name="smart_reply_button_padding_vertical">10dp</dimen>
+ <dimen name="smart_reply_button_padding_horizontal_single_line">12dp</dimen>
+ <dimen name="smart_reply_button_padding_horizontal_double_line">16dp</dimen>
+ <dimen name="smart_reply_button_min_height">40dp</dimen>
<dimen name="smart_reply_button_font_size">14sp</dimen>
+ <dimen name="smart_reply_button_line_spacing_extra">6sp</dimen> <!-- Total line height 20sp. -->
<dimen name="fingerprint_dialog_icon_size">44dp</dimen>
<dimen name="fingerprint_dialog_fp_icon_size">60dp</dimen>
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java
index 57fc03c..790135f 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java
@@ -4,27 +4,105 @@
import android.app.RemoteInput;
import android.content.Context;
import android.content.Intent;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.drawable.GradientDrawable;
+import android.graphics.drawable.RippleDrawable;
import android.os.Bundle;
+import android.text.Layout;
+import android.text.TextPaint;
+import android.text.method.TransformationMethod;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
+import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
-import android.widget.LinearLayout;
+import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.Dependency;
import com.android.systemui.R;
+import java.text.BreakIterator;
+import java.util.Comparator;
+import java.util.PriorityQueue;
+
/** View which displays smart reply buttons in notifications. */
-public class SmartReplyView extends LinearLayout {
+public class SmartReplyView extends ViewGroup {
private static final String TAG = "SmartReplyView";
+ private static final int MEASURE_SPEC_ANY_WIDTH =
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+
+ private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
+ (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
+ - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
+
+ private static final int SQUEEZE_FAILED = -1;
+
private final SmartReplyConstants mConstants;
+ /** Spacing to be applied between views. */
+ private final int mSpacing;
+
+ /** Horizontal padding of smart reply buttons if all of them use only one line of text. */
+ private final int mSingleLineButtonPaddingHorizontal;
+
+ /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
+ private final int mDoubleLineButtonPaddingHorizontal;
+
+ /** Increase in width of a smart reply button as a result of using two lines instead of one. */
+ private final int mSingleToDoubleLineButtonWidthIncrease;
+
+ private final BreakIterator mBreakIterator;
+
+ private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
+
public SmartReplyView(Context context, AttributeSet attrs) {
super(context, attrs);
mConstants = Dependency.get(SmartReplyConstants.class);
+
+ int spacing = 0;
+ int singleLineButtonPaddingHorizontal = 0;
+ int doubleLineButtonPaddingHorizontal = 0;
+
+ final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
+ 0, 0);
+ final int length = arr.getIndexCount();
+ for (int i = 0; i < length; i++) {
+ int attr = arr.getIndex(i);
+ switch (attr) {
+ case R.styleable.SmartReplyView_spacing:
+ spacing = arr.getDimensionPixelSize(i, 0);
+ break;
+ case R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal:
+ singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
+ break;
+ case R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal:
+ doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
+ break;
+ }
+ }
+ arr.recycle();
+
+ mSpacing = spacing;
+ mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
+ mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
+ mSingleToDoubleLineButtonWidthIncrease =
+ 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
+
+ mBreakIterator = BreakIterator.getLineInstance();
+ reallocateCandidateButtonQueueForSqueezing();
+ }
+
+ private void reallocateCandidateButtonQueueForSqueezing() {
+ // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
+ // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
+ // (2) growing in onMeasure.
+ // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
+ mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
+ Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
}
public void setRepliesFromRemoteInput(RemoteInput remoteInput, PendingIntent pendingIntent) {
@@ -39,6 +117,7 @@
}
}
}
+ reallocateCandidateButtonQueueForSqueezing();
}
public static SmartReplyView inflate(Context context, ViewGroup root) {
@@ -46,7 +125,8 @@
LayoutInflater.from(context).inflate(R.layout.smart_reply_view, root, false);
}
- private static Button inflateReplyButton(Context context, ViewGroup root, CharSequence choice,
+ @VisibleForTesting
+ static Button inflateReplyButton(Context context, ViewGroup root, CharSequence choice,
RemoteInput remoteInput, PendingIntent pendingIntent) {
Button b = (Button) LayoutInflater.from(context).inflate(
R.layout.smart_reply_button, root, false);
@@ -65,4 +145,376 @@
});
return b;
}
+
+ @Override
+ public LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new LayoutParams(mContext, attrs);
+ }
+
+ @Override
+ protected LayoutParams generateDefaultLayoutParams() {
+ return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
+ }
+
+ @Override
+ protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
+ return new LayoutParams(params.width, params.height);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
+ ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
+
+ // Mark all buttons as hidden and un-squeezed.
+ resetButtonsLayoutParams();
+
+ if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
+ Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
+ mCandidateButtonQueueForSqueezing.clear();
+ }
+
+ int measuredWidth = mPaddingLeft + mPaddingRight;
+ int maxChildHeight = 0;
+ int displayedChildCount = 0;
+ int buttonPaddingHorizontal = mSingleLineButtonPaddingHorizontal;
+
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
+ continue;
+ }
+
+ child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
+ buttonPaddingHorizontal, child.getPaddingBottom());
+ child.measure(MEASURE_SPEC_ANY_WIDTH, heightMeasureSpec);
+
+ final int lineCount = ((Button) child).getLineCount();
+ if (lineCount < 1 || lineCount > 2) {
+ // If smart reply has no text, or more than two lines, then don't show it.
+ continue;
+ }
+
+ if (lineCount == 1) {
+ mCandidateButtonQueueForSqueezing.add((Button) child);
+ }
+
+ // Remember the current measurements in case the current button doesn't fit in.
+ final int originalMaxChildHeight = maxChildHeight;
+ final int originalMeasuredWidth = measuredWidth;
+ final int originalButtonPaddingHorizontal = buttonPaddingHorizontal;
+
+ final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
+ final int childWidth = child.getMeasuredWidth();
+ final int childHeight = child.getMeasuredHeight();
+ measuredWidth += spacing + childWidth;
+ maxChildHeight = Math.max(maxChildHeight, childHeight);
+
+ // Do we need to increase the number of lines in smart reply buttons to two?
+ final boolean increaseToTwoLines =
+ buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal
+ && (lineCount == 2 || measuredWidth > targetWidth);
+ if (increaseToTwoLines) {
+ measuredWidth += (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
+ buttonPaddingHorizontal = mDoubleLineButtonPaddingHorizontal;
+ }
+
+ // If the last button doesn't fit into the remaining width, try squeezing preceding
+ // smart reply buttons.
+ if (measuredWidth > targetWidth) {
+ // Keep squeezing preceding and current smart reply buttons until they all fit.
+ while (measuredWidth > targetWidth
+ && !mCandidateButtonQueueForSqueezing.isEmpty()) {
+ final Button candidate = mCandidateButtonQueueForSqueezing.poll();
+ final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
+ if (squeezeReduction != SQUEEZE_FAILED) {
+ maxChildHeight = Math.max(maxChildHeight, candidate.getMeasuredHeight());
+ measuredWidth -= squeezeReduction;
+ }
+ }
+
+ // If the current button still doesn't fit after squeezing all buttons, undo the
+ // last squeezing round.
+ if (measuredWidth > targetWidth) {
+ measuredWidth = originalMeasuredWidth;
+ maxChildHeight = originalMaxChildHeight;
+ buttonPaddingHorizontal = originalButtonPaddingHorizontal;
+
+ // Mark all buttons from the last squeezing round as "failed to squeeze", so
+ // that they're re-measured without squeezing later.
+ markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_FAILED, i);
+
+ // The current button doesn't fit, so there's no point in measuring further
+ // buttons.
+ break;
+ }
+
+ // The current button fits, so mark all squeezed buttons as "successfully squeezed"
+ // to prevent them from being un-squeezed in a subsequent squeezing round.
+ markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, i);
+ }
+
+ lp.show = true;
+ displayedChildCount++;
+ }
+
+ // We're done squeezing buttons, so we can clear the priority queue.
+ mCandidateButtonQueueForSqueezing.clear();
+
+ // Finally, we need to update corner radius and re-measure some buttons.
+ updateCornerRadiusAndRemeasureButtonsIfNecessary(buttonPaddingHorizontal, maxChildHeight);
+
+ setMeasuredDimension(
+ resolveSize(Math.max(getSuggestedMinimumWidth(), measuredWidth), widthMeasureSpec),
+ resolveSize(Math.max(getSuggestedMinimumHeight(),
+ mPaddingTop + maxChildHeight + mPaddingBottom), heightMeasureSpec));
+ }
+
+ private void resetButtonsLayoutParams() {
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ lp.show = false;
+ lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
+ }
+ }
+
+ private int squeezeButton(Button button, int heightMeasureSpec) {
+ final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
+ if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
+ return SQUEEZE_FAILED;
+ }
+ return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
+ }
+
+ private int estimateOptimalSqueezedButtonTextWidth(Button button) {
+ // Find a line-break point in the middle of the smart reply button text.
+ final String rawText = button.getText().toString();
+
+ // The button sometimes has a transformation affecting text layout (e.g. all caps).
+ final TransformationMethod transformation = button.getTransformationMethod();
+ final String text = transformation == null ?
+ rawText : transformation.getTransformation(rawText, button).toString();
+ final int length = text.length();
+ mBreakIterator.setText(text);
+
+ if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
+ if (mBreakIterator.next() == BreakIterator.DONE) {
+ // Can't find a single possible line break in either direction.
+ return SQUEEZE_FAILED;
+ }
+ }
+
+ final TextPaint paint = button.getPaint();
+ final int initialPosition = mBreakIterator.current();
+ final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
+ final float initialRightTextWidth =
+ Layout.getDesiredWidth(text, initialPosition, length, paint);
+ float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
+
+ if (initialLeftTextWidth != initialRightTextWidth) {
+ // See if there's a better line-break point (leading to a more narrow button) in
+ // either left or right direction.
+ final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
+ final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
+ for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
+ final int newPosition =
+ moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
+ if (newPosition == BreakIterator.DONE) {
+ break;
+ }
+
+ final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
+ final float newRightTextWidth =
+ Layout.getDesiredWidth(text, newPosition, length, paint);
+ final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
+ if (newOptimalTextWidth < optimalTextWidth) {
+ optimalTextWidth = newOptimalTextWidth;
+ } else {
+ break;
+ }
+
+ boolean tooFar = moveLeft
+ ? newLeftTextWidth <= newRightTextWidth
+ : newLeftTextWidth >= newRightTextWidth;
+ if (tooFar) {
+ break;
+ }
+ }
+ }
+
+ return (int) Math.ceil(optimalTextWidth);
+ }
+
+ private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
+ int oldWidth = button.getMeasuredWidth();
+ if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
+ // Correct for the fact that the button was laid out with single-line horizontal
+ // padding.
+ oldWidth += mSingleToDoubleLineButtonWidthIncrease;
+ }
+
+ // Re-measure the squeezed smart reply button.
+ button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
+ mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
+ final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
+ 2 * mDoubleLineButtonPaddingHorizontal + textWidth, MeasureSpec.AT_MOST);
+ button.measure(widthMeasureSpec, heightMeasureSpec);
+
+ final int newWidth = button.getMeasuredWidth();
+
+ final LayoutParams lp = (LayoutParams) button.getLayoutParams();
+ if (button.getLineCount() > 2 || newWidth >= oldWidth) {
+ lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
+ return SQUEEZE_FAILED;
+ } else {
+ lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
+ return oldWidth - newWidth;
+ }
+ }
+
+ private void updateCornerRadiusAndRemeasureButtonsIfNecessary(
+ int buttonPaddingHorizontal, int maxChildHeight) {
+ final float cornerRadius = ((float) maxChildHeight) / 2;
+ final int maxChildHeightMeasure =
+ MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
+
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (!lp.show) {
+ continue;
+ }
+
+ // Update corner radius.
+ GradientDrawable backgroundDrawable =
+ (GradientDrawable) ((RippleDrawable) child.getBackground()).getDrawable(0);
+ backgroundDrawable.setCornerRadius(cornerRadius);
+
+ boolean requiresNewMeasure = false;
+ int newWidth = child.getMeasuredWidth();
+
+ // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
+ // in more than two lines or because it was unnecessary).
+ if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
+ requiresNewMeasure = true;
+ newWidth = Integer.MAX_VALUE;
+ }
+
+ // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
+ // measured with the wrong number of lines).
+ if (child.getPaddingLeft() != buttonPaddingHorizontal) {
+ requiresNewMeasure = true;
+ if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
+ // Decrease padding (2->1 line).
+ newWidth -= mSingleToDoubleLineButtonWidthIncrease;
+ } else {
+ // Increase padding (1->2 lines).
+ newWidth += mSingleToDoubleLineButtonWidthIncrease;
+ }
+ child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
+ buttonPaddingHorizontal, child.getPaddingBottom());
+ }
+
+ // Re-measure reason 3: The button's height is less than the max height of all buttons
+ // (all should have the same height).
+ if (child.getMeasuredHeight() != maxChildHeight) {
+ requiresNewMeasure = true;
+ }
+
+ if (requiresNewMeasure) {
+ child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
+ maxChildHeightMeasure);
+ }
+ }
+ }
+
+ private void markButtonsWithPendingSqueezeStatusAs(int squeezeStatus, int maxChildIndex) {
+ for (int i = 0; i <= maxChildIndex; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
+ lp.squeezeStatus = squeezeStatus;
+ }
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+
+ final int width = right - left;
+ int position = isRtl ? width - mPaddingRight : mPaddingLeft;
+
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (!lp.show) {
+ continue;
+ }
+
+ final int childWidth = child.getMeasuredWidth();
+ final int childHeight = child.getMeasuredHeight();
+ final int childLeft = isRtl ? position - childWidth : position;
+ child.layout(childLeft, 0, childLeft + childWidth, childHeight);
+
+ final int childWidthWithSpacing = childWidth + mSpacing;
+ if (isRtl) {
+ position -= childWidthWithSpacing;
+ } else {
+ position += childWidthWithSpacing;
+ }
+ }
+ }
+
+ @Override
+ protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ return lp.show && super.drawChild(canvas, child, drawingTime);
+ }
+
+ @VisibleForTesting
+ static class LayoutParams extends ViewGroup.LayoutParams {
+
+ /** Button is not squeezed. */
+ private static final int SQUEEZE_STATUS_NONE = 0;
+
+ /**
+ * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
+ * turns out to have been unnecessary (because there's still not enough space to add another
+ * button).
+ */
+ private static final int SQUEEZE_STATUS_PENDING = 1;
+
+ /** Button was successfully squeezed and it won't be un-squeezed. */
+ private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
+
+ /**
+ * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
+ * text or it didn't reduce the button's width at all. The button will have to be
+ * re-measured to use only one line of text.
+ */
+ private static final int SQUEEZE_STATUS_FAILED = 3;
+
+ private boolean show = false;
+ private int squeezeStatus = SQUEEZE_STATUS_NONE;
+
+ private LayoutParams(Context c, AttributeSet attrs) {
+ super(c, attrs);
+ }
+
+ private LayoutParams(int width, int height) {
+ super(width, height);
+ }
+
+ @VisibleForTesting
+ boolean isShown() {
+ return show;
+ }
+ }
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/SmartReplyViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/SmartReplyViewTest.java
index 0c3637d..58abf19 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/SmartReplyViewTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/policy/SmartReplyViewTest.java
@@ -14,16 +14,27 @@
package com.android.systemui.statusbar.policy;
+import static android.view.View.MeasureSpec;
+
import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+import static junit.framework.Assert.fail;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.Intent;
import android.content.IntentFilter;
+import android.content.res.Resources;
import android.support.test.filters.SmallTest;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import com.android.systemui.R;
import com.android.systemui.SysuiTestCase;
import org.junit.Before;
@@ -34,30 +45,40 @@
@TestableLooper.RunWithLooper
@SmallTest
public class SmartReplyViewTest extends SysuiTestCase {
-
private static final String TEST_RESULT_KEY = "test_result_key";
private static final String TEST_ACTION = "com.android.ACTION";
+
private static final String[] TEST_CHOICES = new String[]{"Hello", "What's up?", "I'm here"};
+ private static final int WIDTH_SPEC = MeasureSpec.makeMeasureSpec(500, MeasureSpec.EXACTLY);
+ private static final int HEIGHT_SPEC = MeasureSpec.makeMeasureSpec(400, MeasureSpec.AT_MOST);
+
private BlockingQueueIntentReceiver mReceiver;
private SmartReplyView mView;
+ private int mSingleLinePaddingHorizontal;
+ private int mDoubleLinePaddingHorizontal;
+ private int mSpacing;
+
@Before
public void setUp() {
mReceiver = new BlockingQueueIntentReceiver();
mContext.registerReceiver(mReceiver, new IntentFilter(TEST_ACTION));
mView = SmartReplyView.inflate(mContext, null);
+
+
+ final Resources res = mContext.getResources();
+ mSingleLinePaddingHorizontal = res.getDimensionPixelSize(
+ R.dimen.smart_reply_button_padding_horizontal_single_line);
+ mDoubleLinePaddingHorizontal = res.getDimensionPixelSize(
+ R.dimen.smart_reply_button_padding_horizontal_double_line);
+ mSpacing = res.getDimensionPixelSize(R.dimen.smart_reply_button_spacing);
}
@Test
public void testSendSmartReply_intentContainsResultsAndSource() throws InterruptedException {
- PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, 0,
- new Intent(TEST_ACTION), 0);
- RemoteInput input = new RemoteInput.Builder(TEST_RESULT_KEY).setChoices(
- TEST_CHOICES).build();
-
- mView.setRepliesFromRemoteInput(input, pendingIntent);
+ setRepliesFromRemoteInput(TEST_CHOICES);
mView.getChildAt(2).performClick();
@@ -66,4 +87,259 @@
RemoteInput.getResultsFromIntent(resultIntent).get(TEST_RESULT_KEY));
assertEquals(RemoteInput.SOURCE_CHOICE, RemoteInput.getResultsSource(resultIntent));
}
+
+ @Test
+ public void testMeasure_empty() {
+ mView.measure(WIDTH_SPEC, HEIGHT_SPEC);
+ assertEquals(500, mView.getMeasuredWidthAndState());
+ assertEquals(0, mView.getMeasuredHeightAndState());
+ }
+
+ @Test
+ public void testLayout_empty() {
+ mView.measure(WIDTH_SPEC, HEIGHT_SPEC);
+ mView.layout(0, 0, 500, 0);
+ }
+
+
+ // Instead of manually calculating the expected measurement/layout results, we build the
+ // expectations as ordinary linear layouts and then check that the relevant parameters in the
+ // corresponding SmartReplyView and LinearView are equal.
+
+ @Test
+ public void testMeasure_shortChoices() {
+ final CharSequence[] choices = new CharSequence[]{"Hi", "Hello", "Bye"};
+
+ // All choices should be displayed as SINGLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(choices, 1);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+
+ assertEqualMeasures(expectedView, mView);
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(0), mView.getChildAt(0));
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(1), mView.getChildAt(1));
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(2), mView.getChildAt(2));
+ }
+
+ @Test
+ public void testLayout_shortChoices() {
+ final CharSequence[] choices = new CharSequence[]{"Hi", "Hello", "Bye"};
+
+ // All choices should be displayed as SINGLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(choices, 1);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+ expectedView.layout(10, 10, 10 + expectedView.getMeasuredWidth(),
+ 10 + expectedView.getMeasuredHeight());
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+ mView.layout(10, 10, 10 + mView.getMeasuredWidth(), 10 + mView.getMeasuredHeight());
+
+ assertEqualLayouts(expectedView, mView);
+ assertEqualLayouts(expectedView.getChildAt(0), mView.getChildAt(0));
+ assertEqualLayouts(expectedView.getChildAt(1), mView.getChildAt(1));
+ assertEqualLayouts(expectedView.getChildAt(2), mView.getChildAt(2));
+ }
+
+ @Test
+ public void testMeasure_choiceWithTwoLines() {
+ final CharSequence[] choices = new CharSequence[]{"Hi", "Hello\neveryone", "Bye"};
+
+ // All choices should be displayed as DOUBLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(choices, 2);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+
+ assertEqualMeasures(expectedView, mView);
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(0), mView.getChildAt(0));
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(1), mView.getChildAt(1));
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(2), mView.getChildAt(2));
+ }
+
+ @Test
+ public void testLayout_choiceWithTwoLines() {
+ final CharSequence[] choices = new CharSequence[]{"Hi", "Hello\neveryone", "Bye"};
+
+ // All choices should be displayed as DOUBLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(choices, 2);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+ expectedView.layout(10, 10, 10 + expectedView.getMeasuredWidth(),
+ 10 + expectedView.getMeasuredHeight());
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+ mView.layout(10, 10, 10 + mView.getMeasuredWidth(), 10 + mView.getMeasuredHeight());
+
+ assertEqualLayouts(expectedView, mView);
+ assertEqualLayouts(expectedView.getChildAt(0), mView.getChildAt(0));
+ assertEqualLayouts(expectedView.getChildAt(1), mView.getChildAt(1));
+ assertEqualLayouts(expectedView.getChildAt(2), mView.getChildAt(2));
+ }
+
+ @Test
+ public void testMeasure_choiceWithThreeLines() {
+ final CharSequence[] choices = new CharSequence[]{"Hi", "Hello\nevery\nbody", "Bye"};
+
+ // The choice with three lines should NOT be displayed. All other choices should be
+ // displayed as SINGLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(new CharSequence[]{"Hi", "Bye"}, 1);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+
+ assertEqualMeasures(expectedView, mView);
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(0), mView.getChildAt(0));
+ assertReplyButtonHidden(mView.getChildAt(1));
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(1), mView.getChildAt(2));
+ }
+
+ @Test
+ public void testLayout_choiceWithThreeLines() {
+ final CharSequence[] choices = new CharSequence[]{"Hi", "Hello\nevery\nbody", "Bye"};
+
+ // The choice with three lines should NOT be displayed. All other choices should be
+ // displayed as SINGLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(new CharSequence[]{"Hi", "Bye"}, 1);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+ expectedView.layout(10, 10, 10 + expectedView.getMeasuredWidth(),
+ 10 + expectedView.getMeasuredHeight());
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+ mView.layout(10, 10, 10 + mView.getMeasuredWidth(), 10 + mView.getMeasuredHeight());
+
+ assertEqualLayouts(expectedView, mView);
+ assertEqualLayouts(expectedView.getChildAt(0), mView.getChildAt(0));
+ // We don't care about mView.getChildAt(1)'s layout because it's hidden (see
+ // testMeasure_choiceWithThreeLines).
+ assertEqualLayouts(expectedView.getChildAt(1), mView.getChildAt(2));
+ }
+
+ @Test
+ public void testMeasure_squeezeLongest() {
+ final CharSequence[] choices = new CharSequence[]{"Short", "Short", "Looooooong replyyyyy"};
+
+ // All choices should be displayed as DOUBLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(
+ new CharSequence[]{"Short", "Short", "Looooooong \nreplyyyyy"}, 2);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(
+ MeasureSpec.makeMeasureSpec(expectedView.getMeasuredWidth(), MeasureSpec.AT_MOST),
+ MeasureSpec.UNSPECIFIED);
+
+ assertEqualMeasures(expectedView, mView);
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(0), mView.getChildAt(0));
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(1), mView.getChildAt(1));
+ assertReplyButtonShownWithEqualMeasures(expectedView.getChildAt(2), mView.getChildAt(2));
+ }
+
+ @Test
+ public void testLayout_squeezeLongest() {
+ final CharSequence[] choices = new CharSequence[]{"Short", "Short", "Looooooong replyyyyy"};
+
+ // All choices should be displayed as DOUBLE-line smart reply buttons.
+ ViewGroup expectedView = buildExpectedView(
+ new CharSequence[]{"Short", "Short", "Looooooong \nreplyyyyy"}, 2);
+ expectedView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+ expectedView.layout(10, 10, 10 + expectedView.getMeasuredWidth(),
+ 10 + expectedView.getMeasuredHeight());
+
+ setRepliesFromRemoteInput(choices);
+ mView.measure(
+ MeasureSpec.makeMeasureSpec(expectedView.getMeasuredWidth(), MeasureSpec.AT_MOST),
+ MeasureSpec.UNSPECIFIED);
+ mView.layout(10, 10, 10 + mView.getMeasuredWidth(), 10 + mView.getMeasuredHeight());
+
+ assertEqualLayouts(expectedView, mView);
+ assertEqualLayouts(expectedView.getChildAt(0), mView.getChildAt(0));
+ assertEqualLayouts(expectedView.getChildAt(1), mView.getChildAt(1));
+ assertEqualLayouts(expectedView.getChildAt(2), mView.getChildAt(2));
+ }
+
+ private void setRepliesFromRemoteInput(CharSequence[] choices) {
+ PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, 0,
+ new Intent(TEST_ACTION), 0);
+ RemoteInput input = new RemoteInput.Builder(TEST_RESULT_KEY).setChoices(choices).build();
+ mView.setRepliesFromRemoteInput(input, pendingIntent);
+ }
+
+ /** Builds a {@link ViewGroup} whose measures and layout mirror a {@link SmartReplyView}. */
+ private ViewGroup buildExpectedView(CharSequence[] choices, int lineCount) {
+ LinearLayout layout = new LinearLayout(mContext);
+ layout.setOrientation(LinearLayout.HORIZONTAL);
+
+ // Baseline alignment causes expected heights to be off by one or two pixels on some
+ // devices.
+ layout.setBaselineAligned(false);
+
+ final boolean isRtl = mView.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ final int paddingHorizontal;
+ switch (lineCount) {
+ case 1:
+ paddingHorizontal = mSingleLinePaddingHorizontal;
+ break;
+ case 2:
+ paddingHorizontal = mDoubleLinePaddingHorizontal;
+ break;
+ default:
+ fail("Invalid line count " + lineCount);
+ return null;
+ }
+
+ Button previous = null;
+ for (CharSequence choice : choices) {
+ Button current = SmartReplyView.inflateReplyButton(mContext, mView, choice, null, null);
+ current.setPadding(paddingHorizontal, current.getPaddingTop(), paddingHorizontal,
+ current.getPaddingBottom());
+ if (previous != null) {
+ ViewGroup.MarginLayoutParams lp =
+ (ViewGroup.MarginLayoutParams) previous.getLayoutParams();
+ if (isRtl) {
+ lp.leftMargin = mSpacing;
+ } else {
+ lp.rightMargin = mSpacing;
+ }
+ }
+ layout.addView(current);
+ previous = current;
+ }
+
+ return layout;
+ }
+
+ private static void assertEqualMeasures(View expected, View actual) {
+ assertEquals(expected.getMeasuredWidth(), actual.getMeasuredWidth());
+ assertEquals(expected.getMeasuredHeight(), actual.getMeasuredHeight());
+ }
+
+ private static void assertReplyButtonShownWithEqualMeasures(View expected, View actual) {
+ assertReplyButtonShown(actual);
+ assertEqualMeasures(expected, actual);
+ assertEquals(expected.getPaddingLeft(), actual.getPaddingLeft());
+ assertEquals(expected.getPaddingTop(), actual.getPaddingTop());
+ assertEquals(expected.getPaddingRight(), actual.getPaddingRight());
+ assertEquals(expected.getPaddingBottom(), actual.getPaddingBottom());
+ }
+
+ private static void assertReplyButtonShown(View view) {
+ assertTrue(((SmartReplyView.LayoutParams) view.getLayoutParams()).isShown());
+ }
+
+ private static void assertReplyButtonHidden(View view) {
+ assertFalse(((SmartReplyView.LayoutParams) view.getLayoutParams()).isShown());
+ }
+
+ private static void assertEqualLayouts(View expected, View actual) {
+ assertEquals(expected.getLeft(), actual.getLeft());
+ assertEquals(expected.getTop(), actual.getTop());
+ assertEquals(expected.getRight(), actual.getRight());
+ assertEquals(expected.getBottom(), actual.getBottom());
+ }
}