Fix notification inline reply animation

This CL fixes the animation for the appearance of the inline reply RemoteInputView, bringing it as close to the Direct spec (https://direct.googleplex.com/#/spec/283080003) as possible. Intentional deviations from the Direct specs are the following:
1. The animation duration is 360 ms instead of 500 ms (in order to be consistent with other notification shade resize animations)
2. The interpolator is FAST_IN_SLOW_OUT, which might differ slightly from the Direct specs "DECELERATE_WITH_EASING" interpolator. (same reason as for 1.)
3. The height of the notification is increased slightly more than in the Direct spec. (This is done in order to keep the paddings and margins as they currently are. It seems to me that if there was more text in the Direct visualization, the top margin of the inline reply EditText would be too small.)

This CL does not fix the following related issues:
1. The collapsed heads up view does not allocate enough space to fit the RemoteInputView. This causes the top part of the notification to get shrunk (Icons jump up by a few pixels), when clicking the "Reply" button. It also affects the beginning of the RemoteInputView expand animation to be partly shifted out of the HeadsUp View.
2. When defocusing the RemoteInputView, the "Reply" button is clipped during the beginning of the animation.
3. When focusing the RemoteInputView, the notification shade is not scrolled correctly. (Keyboard is overlapping RemoteInputView)
4. The expand/collapse animation is broken while the RemoteInputView is visible.
I will address these issues in seperate CLs.

Bug: 174148361
Test: atest RemoteInputViewTest, Manual, i.e. posting various types of Notifications from the Notify2-RVC application with a reply action added to them. Then observing the animations visually and analyzing frames from screen recordings.
Change-Id: I8b2e821b254d6d7fdfbca22426eceafff95d5322
This commit is contained in:
Johannes Gallmann
2022-10-14 09:58:09 +00:00
parent ccafe39e56
commit b642af90a1
9 changed files with 268 additions and 21 deletions

View File

@@ -27,6 +27,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:layout_gravity="bottom"
android:orientation="horizontal"
android:background="@color/notification_action_list_background_color"
>

View File

@@ -221,6 +221,7 @@ android_library {
"WindowManager-Shell",
"LowLightDreamLib",
"motion_tool_lib",
"androidx.core_core-animation-testing-nodeps",
],
}

View File

@@ -20,6 +20,7 @@
<com.android.systemui.statusbar.policy.RemoteInputView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/remote_input"
android:forceHasOverlappingRendering="false"
android:layout_height="match_parent"
android:layout_width="match_parent">
<LinearLayout

View File

@@ -100,6 +100,11 @@ object Flags {
// TODO(b/257315550): Tracking Bug
val NO_HUN_FOR_OLD_WHEN = unreleasedFlag(118, "no_hun_for_old_when")
// TODO(b/260335638): Tracking Bug
@JvmField
val NOTIFICATION_INLINE_REPLY_ANIMATION =
unreleasedFlag(174148361, "notification_inline_reply_animation", teamfood = true)
val FILTER_UNSEEN_NOTIFS_ON_KEYGUARD =
unreleasedFlag(254647461, "filter_unseen_notifs_on_keyguard", teamfood = true)

View File

@@ -463,7 +463,11 @@ public class NotificationRemoteInputManager implements Dumpable {
riv.getController().setRemoteInput(input);
riv.getController().setRemoteInputs(inputs);
riv.getController().setEditedSuggestionInfo(editedSuggestionInfo);
riv.focusAnimated();
ViewGroup parent = view.getParent() != null ? (ViewGroup) view.getParent() : null;
if (parent != null) {
riv.setDefocusTargetHeight(parent.getHeight());
}
riv.focusAnimated(parent);
if (userMessageContent != null) {
riv.setEditTextContent(userMessageContent);
}

View File

@@ -70,7 +70,7 @@ import java.util.List;
/**
* A frame layout containing the actual payload of the notification, including the contracted,
* expanded and heads up layout. This class is responsible for clipping the content and and
* expanded and heads up layout. This class is responsible for clipping the content and
* switching between the expanded, contracted and the heads up view depending on its clipped size.
*/
public class NotificationContentView extends FrameLayout implements NotificationFadeAware {

View File

@@ -18,8 +18,8 @@ package com.android.systemui.statusbar.policy;
import static android.view.WindowInsetsAnimation.Callback.DISPATCH_MODE_STOP;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import static com.android.systemui.statusbar.notification.stack.StackStateAnimator.ANIMATION_DURATION_STANDARD;
import android.app.ActivityManager;
import android.app.Notification;
import android.content.Context;
@@ -57,6 +57,7 @@ import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
@@ -67,6 +68,11 @@ import android.window.OnBackInvokedDispatcher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.animation.Animator;
import androidx.core.animation.AnimatorListenerAdapter;
import androidx.core.animation.AnimatorSet;
import androidx.core.animation.ObjectAnimator;
import androidx.core.animation.ValueAnimator;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.graphics.ColorUtils;
@@ -74,6 +80,7 @@ import com.android.internal.logging.UiEvent;
import com.android.internal.logging.UiEventLogger;
import com.android.systemui.Dependency;
import com.android.systemui.R;
import com.android.systemui.animation.InterpolatorsAndroidX;
import com.android.systemui.statusbar.RemoteInputController;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper;
@@ -97,6 +104,12 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
// A marker object that let's us easily find views of this class.
public static final Object VIEW_TAG = new Object();
private static final long FOCUS_ANIMATION_TOTAL_DURATION = ANIMATION_DURATION_STANDARD;
private static final long FOCUS_ANIMATION_CROSSFADE_DURATION = 50;
private static final long FOCUS_ANIMATION_FADE_IN_DELAY = 33;
private static final long FOCUS_ANIMATION_FADE_IN_DURATION = 83;
private static final float FOCUS_ANIMATION_MIN_SCALE = 0.5f;
public final Object mToken = new Object();
private final SendButtonTextWatcher mTextWatcher;
@@ -108,6 +121,7 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
private RemoteEditText mEditText;
private ImageButton mSendButton;
private LinearLayout mContentView;
private GradientDrawable mContentBackground;
private ProgressBar mProgressBar;
private ImageView mDelete;
@@ -115,7 +129,10 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
private boolean mColorized;
private int mTint;
private boolean mResetting;
@Nullable private RevealParams mRevealParams;
@Nullable
private RevealParams mRevealParams;
private Rect mContentBackgroundBounds;
private boolean mIsFocusAnimationFlagActive;
// TODO(b/193539698): move these to a Controller
private RemoteInputController mController;
@@ -125,6 +142,9 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
private boolean mSending;
private NotificationViewWrapper mWrapper;
private Integer mDefocusTargetHeight = null;
// TODO(b/193539698): remove this; views shouldn't have access to their controller, and places
// that need the controller shouldn't have access to the view
private RemoteInputViewController mViewController;
@@ -255,8 +275,8 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
mDeleteBg.setImageTintBlendMode(BlendMode.SRC_IN);
mDelete.setImageTintBlendMode(BlendMode.SRC_IN);
mDelete.setOnClickListener(v -> setAttachment(null));
LinearLayout contentView = findViewById(R.id.remote_input_content);
contentView.setBackground(mContentBackground);
mContentView = findViewById(R.id.remote_input_content);
mContentView.setBackground(mContentBackground);
mEditText = findViewById(R.id.remote_input_text);
mEditText.setInnerFocusable(false);
// TextView initializes the spell checked when the view is attached to a window.
@@ -398,20 +418,70 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
return true;
}
private void onDefocus(boolean animate, boolean logClose) {
/**
* View will ensure to use at most the provided defocusTargetHeight, when defocusing animated.
* This is to ensure that the parent can resize itself to the targetHeight while the defocus
* animation of the RemoteInputView is running.
*
* @param defocusTargetHeight The target height the parent will resize itself to. If null, the
* RemoteInputView will not resize itself.
*/
public void setDefocusTargetHeight(Integer defocusTargetHeight) {
mDefocusTargetHeight = defocusTargetHeight;
}
@VisibleForTesting
void onDefocus(boolean animate, boolean logClose) {
mController.removeRemoteInput(mEntry, mToken);
mEntry.remoteInputText = mEditText.getText();
// During removal, we get reattached and lose focus. Not hiding in that
// case to prevent flicker.
if (!mRemoved) {
if (animate && mRevealParams != null && mRevealParams.radius > 0) {
Animator reveal = mRevealParams.createCircularHideAnimator(this);
reveal.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN);
reveal.setDuration(StackStateAnimator.ANIMATION_DURATION_CLOSE_REMOTE_INPUT);
reveal.addListener(new AnimatorListenerAdapter() {
if (animate && mIsFocusAnimationFlagActive) {
Animator animator = getDefocusAnimator();
// When defocusing, the notification needs to shrink. Therefore, we need to free
// up the space that is needed for the RemoteInputView. This is done by setting
// a negative top margin of the height difference of the RemoteInputView and its
// sibling (the actions_container_layout containing the Reply button)
if (mDefocusTargetHeight != null && mDefocusTargetHeight < getHeight()
&& mDefocusTargetHeight >= 0
&& getLayoutParams() instanceof FrameLayout.LayoutParams) {
int heightToShrink = getHeight() - mDefocusTargetHeight;
FrameLayout.LayoutParams layoutParams =
(FrameLayout.LayoutParams) getLayoutParams();
layoutParams.topMargin = -heightToShrink;
setLayoutParams(layoutParams);
((ViewGroup) getParent().getParent()).setClipChildren(false);
}
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
//reset top margin after the animation
if (getLayoutParams() instanceof FrameLayout.LayoutParams) {
FrameLayout.LayoutParams layoutParams =
(FrameLayout.LayoutParams) getLayoutParams();
layoutParams.topMargin = 0;
setLayoutParams(layoutParams);
((ViewGroup) getParent().getParent()).setClipChildren(true);
}
setVisibility(GONE);
if (mWrapper != null) {
mWrapper.setRemoteInputVisible(false);
}
}
});
animator.start();
} else if (animate && mRevealParams != null && mRevealParams.radius > 0) {
android.animation.Animator reveal = mRevealParams.createCircularHideAnimator(this);
reveal.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN);
reveal.setDuration(StackStateAnimator.ANIMATION_DURATION_CLOSE_REMOTE_INPUT);
reveal.addListener(new android.animation.AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(android.animation.Animator animation) {
setVisibility(GONE);
if (mWrapper != null) {
mWrapper.setRemoteInputVisible(false);
@@ -533,12 +603,29 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
mEditText.setText(editTextContent);
}
public void focusAnimated() {
if (getVisibility() != VISIBLE && mRevealParams != null) {
Animator animator = mRevealParams.createCircularRevealAnimator(this);
/**
* Sets whether the feature flag for the updated inline reply animation is active or not.
* @param active
*/
public void setIsFocusAnimationFlagActive(boolean active) {
mIsFocusAnimationFlagActive = active;
}
/**
* Focuses the RemoteInputView and animates its appearance
*
* @param crossFadeView view that will be crossfaded during the appearance animation
*/
public void focusAnimated(View crossFadeView) {
if (!mIsFocusAnimationFlagActive && getVisibility() != VISIBLE
&& mRevealParams != null) {
android.animation.Animator animator = mRevealParams.createCircularRevealAnimator(this);
animator.setDuration(StackStateAnimator.ANIMATION_DURATION_STANDARD);
animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN);
animator.start();
} else if (mIsFocusAnimationFlagActive && getVisibility() != VISIBLE) {
setAlpha(0f);
getFocusAnimator(crossFadeView).start();
}
focus();
}
@@ -737,6 +824,81 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
mOnSendListeners.remove(listener);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mIsFocusAnimationFlagActive) setPivotY(getMeasuredHeight());
if (mContentBackgroundBounds != null) {
mContentBackground.setBounds(mContentBackgroundBounds);
}
}
private Animator getFocusAnimator(View crossFadeView) {
final Animator alphaAnimator = ObjectAnimator.ofFloat(this, View.ALPHA, 0f, 1f);
alphaAnimator.setStartDelay(FOCUS_ANIMATION_FADE_IN_DELAY);
alphaAnimator.setDuration(FOCUS_ANIMATION_FADE_IN_DURATION);
alphaAnimator.setInterpolator(InterpolatorsAndroidX.LINEAR);
ValueAnimator scaleAnimator = ValueAnimator.ofFloat(FOCUS_ANIMATION_MIN_SCALE, 1f);
scaleAnimator.addUpdateListener(valueAnimator -> {
setFocusAnimationScaleY((float) scaleAnimator.getAnimatedValue());
});
scaleAnimator.setDuration(FOCUS_ANIMATION_TOTAL_DURATION);
scaleAnimator.setInterpolator(InterpolatorsAndroidX.FAST_OUT_SLOW_IN);
final Animator crossFadeViewAlphaAnimator =
ObjectAnimator.ofFloat(crossFadeView, View.ALPHA, 1f, 0f);
crossFadeViewAlphaAnimator.setDuration(FOCUS_ANIMATION_CROSSFADE_DURATION);
crossFadeViewAlphaAnimator.setInterpolator(InterpolatorsAndroidX.LINEAR);
alphaAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation, boolean isReverse) {
crossFadeView.setAlpha(1f);
}
});
final AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(alphaAnimator, scaleAnimator, crossFadeViewAlphaAnimator);
return animatorSet;
}
private Animator getDefocusAnimator() {
final Animator alphaAnimator = ObjectAnimator.ofFloat(this, View.ALPHA, 1f, 0f);
alphaAnimator.setDuration(FOCUS_ANIMATION_CROSSFADE_DURATION);
alphaAnimator.setInterpolator(InterpolatorsAndroidX.LINEAR);
ValueAnimator scaleAnimator = ValueAnimator.ofFloat(1f, FOCUS_ANIMATION_MIN_SCALE);
scaleAnimator.addUpdateListener(valueAnimator -> {
setFocusAnimationScaleY((float) scaleAnimator.getAnimatedValue());
});
scaleAnimator.setDuration(FOCUS_ANIMATION_TOTAL_DURATION);
scaleAnimator.setInterpolator(InterpolatorsAndroidX.FAST_OUT_SLOW_IN);
scaleAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation, boolean isReverse) {
setFocusAnimationScaleY(1f);
}
});
final AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(alphaAnimator, scaleAnimator);
return animatorSet;
}
/**
* Sets affected view properties for a vertical scale animation
*
* @param scaleY desired vertical view scale
*/
private void setFocusAnimationScaleY(float scaleY) {
int verticalBoundOffset = (int) ((1f - scaleY) * 0.5f * mContentView.getHeight());
mContentBackgroundBounds = new Rect(0, verticalBoundOffset, mContentView.getWidth(),
mContentView.getHeight() - verticalBoundOffset);
mContentBackground.setBounds(mContentBackgroundBounds);
mContentView.setBackground(mContentBackground);
setTranslationY(verticalBoundOffset);
}
/** Handler for button click on send action in IME. */
private class EditorActionHandler implements TextView.OnEditorActionListener {
@@ -991,11 +1153,11 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene
this.radius = radius;
}
Animator createCircularHideAnimator(View view) {
android.animation.Animator createCircularHideAnimator(View view) {
return ViewAnimationUtils.createCircularReveal(view, centerX, centerY, radius, 0);
}
Animator createCircularRevealAnimator(View view) {
android.animation.Animator createCircularRevealAnimator(View view) {
return ViewAnimationUtils.createCircularReveal(view, centerX, centerY, 0, radius);
}
}

View File

@@ -30,6 +30,8 @@ import android.util.Log
import android.view.View
import com.android.internal.logging.UiEventLogger
import com.android.systemui.R
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.flags.Flags.NOTIFICATION_INLINE_REPLY_ANIMATION
import com.android.systemui.statusbar.NotificationRemoteInputManager
import com.android.systemui.statusbar.RemoteInputController
import com.android.systemui.statusbar.notification.collection.NotificationEntry
@@ -61,6 +63,8 @@ interface RemoteInputViewController {
var revealParams: RevealParams?
val isFocusAnimationFlagActive: Boolean
/**
* Sets the smart reply that should be inserted in the remote input, or `null` if the user is
* not editing a smart reply.
@@ -117,7 +121,8 @@ class RemoteInputViewControllerImpl @Inject constructor(
private val remoteInputQuickSettingsDisabler: RemoteInputQuickSettingsDisabler,
private val remoteInputController: RemoteInputController,
private val shortcutManager: ShortcutManager,
private val uiEventLogger: UiEventLogger
private val uiEventLogger: UiEventLogger,
private val mFlags: FeatureFlags
) : RemoteInputViewController {
private val onSendListeners = ArraySet<OnSendRemoteInputListener>()
@@ -149,6 +154,9 @@ class RemoteInputViewControllerImpl @Inject constructor(
override val isActive: Boolean get() = view.isActive
override val isFocusAnimationFlagActive: Boolean
get() = mFlags.isEnabled(NOTIFICATION_INLINE_REPLY_ANIMATION)
override fun bind() {
if (isBound) return
isBound = true
@@ -159,6 +167,7 @@ class RemoteInputViewControllerImpl @Inject constructor(
view.setSupportedMimeTypes(it.allowedDataTypes)
}
view.setRevealParameters(revealParams)
view.setIsFocusAnimationFlagActive(isFocusAnimationFlagActive)
view.addOnEditTextFocusChangedListener(onFocusChangeListener)
view.addOnSendRemoteInputListener(onSendRemoteInputListener)

View File

@@ -16,6 +16,8 @@ package com.android.systemui.statusbar.policy;
import static android.view.ContentInfo.SOURCE_CLIPBOARD;
import static com.android.systemui.statusbar.notification.stack.StackStateAnimator.ANIMATION_DURATION_STANDARD;
import static com.google.common.truth.Truth.assertThat;
import static junit.framework.Assert.assertEquals;
@@ -57,6 +59,7 @@ import android.window.OnBackInvokedDispatcher;
import android.window.WindowOnBackInvokedDispatcher;
import androidx.annotation.NonNull;
import androidx.core.animation.AnimatorTestRule;
import androidx.test.filters.SmallTest;
import com.android.internal.logging.UiEventLogger;
@@ -64,15 +67,19 @@ import com.android.internal.logging.testing.UiEventLoggerFake;
import com.android.systemui.Dependency;
import com.android.systemui.R;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.flags.FakeFeatureFlags;
import com.android.systemui.flags.Flags;
import com.android.systemui.statusbar.NotificationRemoteInputManager;
import com.android.systemui.statusbar.RemoteInputController;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.row.NotificationTestHelper;
import com.android.systemui.statusbar.notification.stack.StackStateAnimator;
import com.android.systemui.statusbar.phone.LightBarController;
import org.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
@@ -99,6 +106,9 @@ public class RemoteInputViewTest extends SysuiTestCase {
private BlockingQueueIntentReceiver mReceiver;
private final UiEventLoggerFake mUiEventLoggerFake = new UiEventLoggerFake();
@ClassRule
public static AnimatorTestRule mAnimatorTestRule = new AnimatorTestRule();
@Before
public void setUp() throws Exception {
allowTestableLooperAsMainThread();
@@ -294,6 +304,9 @@ public class RemoteInputViewTest extends SysuiTestCase {
/* invoke the captured callback */
onBackInvokedCallbackCaptor.getValue().onBackInvoked();
/* wait for RemoteInputView disappear animation to finish */
mAnimatorTestRule.advanceTimeBy(StackStateAnimator.ANIMATION_DURATION_STANDARD);
/* verify that the RemoteInputView goes away */
assertEquals(view.getVisibility(), View.GONE);
}
@@ -363,19 +376,70 @@ public class RemoteInputViewTest extends SysuiTestCase {
mUiEventLoggerFake.eventId(1));
}
@Test
public void testFocusAnimation() throws Exception {
NotificationTestHelper helper = new NotificationTestHelper(
mContext,
mDependency,
TestableLooper.get(this));
ExpandableNotificationRow row = helper.createRow();
RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
bindController(view, row.getEntry());
view.setVisibility(View.GONE);
View crossFadeView = new View(mContext);
// Start focus animation
view.focusAnimated(crossFadeView);
// fast forward to end of animation
mAnimatorTestRule.advanceTimeBy(ANIMATION_DURATION_STANDARD);
// assert that crossFadeView's alpha is reset to 1f after the animation (hidden behind
// RemoteInputView)
assertEquals(1f, crossFadeView.getAlpha());
assertEquals(View.VISIBLE, view.getVisibility());
assertEquals(1f, view.getAlpha());
}
@Test
public void testDefocusAnimation() throws Exception {
NotificationTestHelper helper = new NotificationTestHelper(
mContext,
mDependency,
TestableLooper.get(this));
ExpandableNotificationRow row = helper.createRow();
RemoteInputView view = RemoteInputView.inflate(mContext, null, row.getEntry(), mController);
bindController(view, row.getEntry());
// Start defocus animation
view.onDefocus(true, false);
assertEquals(View.VISIBLE, view.getVisibility());
// fast forward to end of animation
mAnimatorTestRule.advanceTimeBy(ANIMATION_DURATION_STANDARD);
// assert that RemoteInputView is no longer visible
assertEquals(View.GONE, view.getVisibility());
}
// NOTE: because we're refactoring the RemoteInputView and moving logic into the
// RemoteInputViewController, it's easiest to just test the system of the two classes together.
// RemoteInputViewController, it's easiest to just test the system of the two classes together.
@NonNull
private RemoteInputViewController bindController(
RemoteInputView view,
NotificationEntry entry) {
FakeFeatureFlags fakeFeatureFlags = new FakeFeatureFlags();
fakeFeatureFlags.set(Flags.NOTIFICATION_INLINE_REPLY_ANIMATION, true);
RemoteInputViewControllerImpl viewController = new RemoteInputViewControllerImpl(
view,
entry,
mRemoteInputQuickSettingsDisabler,
mController,
mShortcutManager,
mUiEventLoggerFake);
mUiEventLoggerFake,
fakeFeatureFlags
);
viewController.bind();
return viewController;
}