diff options
| author | 2018-02-16 21:43:37 +0000 | |
|---|---|---|
| committer | 2018-02-16 21:43:37 +0000 | |
| commit | 8434214c17824a1d3fe133ad50f58445bf5b9e55 (patch) | |
| tree | 2fa0405bb8e915a0e924120b87b132a2e500a622 | |
| parent | 385ccb10b29656e8cfdab261236f6101af53ac68 (diff) | |
| parent | 102431d5ebb138c1c0c6ebb4f5cae4032f7b41ad (diff) | |
Merge "Custom SmartReplyView layout"
8 files changed, 764 insertions, 21 deletions
diff --git a/packages/SystemUI/res/drawable/smart_reply_button_background.xml b/packages/SystemUI/res/drawable/smart_reply_button_background.xml index 1cd1451008b4..c5ac67beb521 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 4ac41d5cf6c3..3c6edcd8a917 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 6d5338697161..6f21787a1524 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 a923f0b8c332..f0a5fe421b84 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 be8e990fb075..c054d16644e4 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 d17cd21b217c..3ff553e156c7 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -887,10 +887,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 57fc03cb7308..790135fc03ca 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.PendingIntent; 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 @@ public class SmartReplyView extends LinearLayout { } } } + reallocateCandidateButtonQueueForSqueezing(); } public static SmartReplyView inflate(Context context, ViewGroup root) { @@ -46,7 +125,8 @@ public class SmartReplyView extends LinearLayout { 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 @@ public class SmartReplyView extends LinearLayout { }); 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 0c3637d6e234..58abf19dd238 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 @@ import org.junit.runner.RunWith; @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 @@ public class SmartReplyViewTest extends SysuiTestCase { 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()); + } } |