1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.chrome.browser.ui.messages.snackbar;
6 
7 import android.animation.Animator;
8 import android.animation.AnimatorListenerAdapter;
9 import android.animation.AnimatorSet;
10 import android.animation.ObjectAnimator;
11 import android.app.Activity;
12 import android.graphics.Rect;
13 import android.graphics.drawable.Drawable;
14 import android.graphics.drawable.GradientDrawable;
15 import android.text.TextUtils;
16 import android.view.Gravity;
17 import android.view.LayoutInflater;
18 import android.view.SurfaceView;
19 import android.view.View;
20 import android.view.View.OnClickListener;
21 import android.view.View.OnLayoutChangeListener;
22 import android.view.ViewGroup;
23 import android.widget.FrameLayout;
24 import android.widget.ImageView;
25 import android.widget.TextView;
26 
27 import androidx.annotation.Nullable;
28 
29 import org.chromium.base.ApiCompatibilityUtils;
30 import org.chromium.chrome.ui.messages.R;
31 import org.chromium.components.browser_ui.widget.animation.Interpolators;
32 import org.chromium.components.browser_ui.widget.text.TemplatePreservingTextView;
33 import org.chromium.ui.base.DeviceFormFactor;
34 import org.chromium.ui.base.WindowAndroid;
35 import org.chromium.ui.interpolators.BakedBezierInterpolator;
36 
37 /**
38  * Visual representation of a snackbar. On phone it matches the width of the activity; on tablet it
39  * has a fixed width and is anchored at the start-bottom corner of the current window.
40  */
41 // TODO (jianli): Change this class and its methods back to package protected after the offline
42 // indicator experiment is done.
43 public class SnackbarView {
44     private static final int MAX_LINES = 5;
45 
46     private final WindowAndroid mWindowAndroid;
47     protected final ViewGroup mContainerView;
48     protected final ViewGroup mSnackbarView;
49     protected final TemplatePreservingTextView mMessageView;
50     private final TextView mActionButtonView;
51     private final ImageView mProfileImageView;
52     private final int mAnimationDuration;
53     private final boolean mIsTablet;
54     private ViewGroup mOriginalParent;
55     protected ViewGroup mParent;
56     protected Snackbar mSnackbar;
57     private View mRootContentView;
58 
59     // Variables used to calculate the virtual keyboard's height.
60     private Rect mCurrentVisibleRect = new Rect();
61     private Rect mPreviousVisibleRect = new Rect();
62     private int[] mTempLocation = new int[2];
63 
64     private OnLayoutChangeListener mLayoutListener = new OnLayoutChangeListener() {
65         @Override
66         public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
67                 int oldTop, int oldRight, int oldBottom) {
68             adjustViewPosition();
69         }
70     };
71 
72     /**
73      * Creates an instance of the {@link SnackbarView}.
74      * @param activity The activity that displays the snackbar.
75      * @param listener An {@link OnClickListener} that will be called when the action button is
76      *                 clicked.
77      * @param snackbar The snackbar to be displayed.
78      * @param parentView The ViewGroup used to display this snackbar.
79      * @param windowAndroid The WindowAndroid used for starting animation. If it is null,
80      *                      Animator#start is called instead.
81      */
SnackbarView(Activity activity, OnClickListener listener, Snackbar snackbar, ViewGroup parentView, @Nullable WindowAndroid windowAndroid)82     public SnackbarView(Activity activity, OnClickListener listener, Snackbar snackbar,
83             ViewGroup parentView, @Nullable WindowAndroid windowAndroid) {
84         mIsTablet = DeviceFormFactor.isNonMultiDisplayContextOnTablet(activity);
85         mOriginalParent = parentView;
86         mWindowAndroid = windowAndroid;
87 
88         mRootContentView = activity.findViewById(android.R.id.content);
89         mParent = mOriginalParent;
90         mContainerView = (ViewGroup) LayoutInflater.from(activity).inflate(
91                 R.layout.snackbar, mParent, false);
92         mSnackbarView = mContainerView.findViewById(R.id.snackbar);
93         mAnimationDuration =
94                 mContainerView.getResources().getInteger(android.R.integer.config_mediumAnimTime);
95         mMessageView =
96                 (TemplatePreservingTextView) mContainerView.findViewById(R.id.snackbar_message);
97         mActionButtonView = (TextView) mContainerView.findViewById(R.id.snackbar_button);
98         mActionButtonView.setOnClickListener(listener);
99         mProfileImageView = (ImageView) mContainerView.findViewById(R.id.snackbar_profile_image);
100 
101         updateInternal(snackbar, false);
102     }
103 
show()104     public void show() {
105         addToParent();
106         mContainerView.addOnLayoutChangeListener(new OnLayoutChangeListener() {
107             @Override
108             public void onLayoutChange(View v, int left, int top, int right, int bottom,
109                     int oldLeft, int oldTop, int oldRight, int oldBottom) {
110                 mContainerView.removeOnLayoutChangeListener(this);
111                 mContainerView.setTranslationY(getYPositionForMoveAnimation());
112                 Animator animator = ObjectAnimator.ofFloat(mContainerView, View.TRANSLATION_Y, 0);
113                 animator.setInterpolator(Interpolators.DECELERATE_INTERPOLATOR);
114                 animator.setDuration(mAnimationDuration);
115                 startAnimatorOnSurfaceView(animator);
116             }
117         });
118     }
119 
dismiss()120     public void dismiss() {
121         // Disable action button during animation.
122         mActionButtonView.setEnabled(false);
123         AnimatorSet animatorSet = new AnimatorSet();
124         animatorSet.setDuration(mAnimationDuration);
125         animatorSet.addListener(new AnimatorListenerAdapter() {
126             @Override
127             public void onAnimationEnd(Animator animation) {
128                 mRootContentView.removeOnLayoutChangeListener(mLayoutListener);
129                 mParent.removeView(mContainerView);
130             }
131         });
132         Animator moveAnimator = ObjectAnimator.ofFloat(
133                 mContainerView, View.TRANSLATION_Y, getYPositionForMoveAnimation());
134         moveAnimator.setInterpolator(Interpolators.DECELERATE_INTERPOLATOR);
135         Animator fadeOut = ObjectAnimator.ofFloat(mContainerView, View.ALPHA, 0f);
136         fadeOut.setInterpolator(BakedBezierInterpolator.FADE_OUT_CURVE);
137 
138         animatorSet.playTogether(fadeOut, moveAnimator);
139         startAnimatorOnSurfaceView(animatorSet);
140     }
141 
142     /**
143      * Adjusts the position of the snackbar on top of the soft keyboard, if any.
144      */
adjustViewPosition()145     void adjustViewPosition() {
146         mParent.getWindowVisibleDisplayFrame(mCurrentVisibleRect);
147         // Only update if the visible frame has changed, otherwise there will be a layout loop.
148         if (!mCurrentVisibleRect.equals(mPreviousVisibleRect)) {
149             mPreviousVisibleRect.set(mCurrentVisibleRect);
150 
151             FrameLayout.LayoutParams lp = getLayoutParams();
152 
153             int prevBottomMargin = lp.bottomMargin;
154             int prevWidth = lp.width;
155             int prevGravity = lp.gravity;
156 
157             lp.bottomMargin = getBottomMarginForLayout();
158             if (mIsTablet) {
159                 int margin = mParent.getResources().getDimensionPixelSize(
160                         R.dimen.snackbar_margin_tablet);
161                 int width =
162                         mParent.getResources().getDimensionPixelSize(R.dimen.snackbar_width_tablet);
163                 lp.width = Math.min(width, mParent.getWidth() - 2 * margin);
164                 lp.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM;
165             }
166 
167             if (prevBottomMargin != lp.bottomMargin || prevWidth != lp.width
168                     || prevGravity != lp.gravity) {
169                 mContainerView.setLayoutParams(lp);
170             }
171         }
172     }
173 
getYPositionForMoveAnimation()174     protected int getYPositionForMoveAnimation() {
175         return mContainerView.getHeight() + getLayoutParams().bottomMargin;
176     }
177 
getBottomMarginForLayout()178     protected int getBottomMarginForLayout() {
179         mParent.getLocationInWindow(mTempLocation);
180         int keyboardHeight = mParent.getHeight() + mTempLocation[1] - mCurrentVisibleRect.bottom;
181         return Math.max(0, keyboardHeight);
182     }
183 
184     /**
185      * @see SnackbarManager#overrideParent(ViewGroup)
186      */
overrideParent(ViewGroup overridingParent)187     void overrideParent(ViewGroup overridingParent) {
188         mRootContentView.removeOnLayoutChangeListener(mLayoutListener);
189         mParent = overridingParent == null ? mOriginalParent : overridingParent;
190         if (isShowing()) {
191             ((ViewGroup) mContainerView.getParent()).removeView(mContainerView);
192         }
193         addToParent();
194     }
195 
isShowing()196     boolean isShowing() {
197         return mContainerView.isShown();
198     }
199 
bringToFront()200     void bringToFront() {
201         mContainerView.bringToFront();
202     }
203 
204     /**
205      * Sends an accessibility event to mMessageView announcing that this window was added so that
206      * the mMessageView content description is read aloud if accessibility is enabled.
207      */
announceforAccessibility()208     public void announceforAccessibility() {
209         StringBuilder accessibilityText = new StringBuilder(mMessageView.getContentDescription());
210         if (mActionButtonView.getContentDescription() != null) {
211             accessibilityText.append(". ")
212                     .append(mActionButtonView.getContentDescription())
213                     .append(". ")
214                     .append(mContainerView.getResources().getString(
215                             R.string.bottom_bar_screen_position));
216         }
217 
218         mMessageView.announceForAccessibility(accessibilityText);
219     }
220 
221     /**
222      * Sends an accessibility event to mContainerView announcing that an action was taken based on
223      * the action button being pressed.  May do nothing if no announcement was specified.
224      */
announceActionForAccessibility()225     public void announceActionForAccessibility() {
226         if (TextUtils.isEmpty(mSnackbar.getActionAccessibilityAnnouncement())) return;
227         mContainerView.announceForAccessibility(mSnackbar.getActionAccessibilityAnnouncement());
228     }
229 
230     /**
231      * Updates the view to display data from the given snackbar. No-op if the view is already
232      * showing the given snackbar.
233      * @param snackbar The snackbar to display
234      * @return Whether update has actually been executed.
235      */
update(Snackbar snackbar)236     boolean update(Snackbar snackbar) {
237         return updateInternal(snackbar, true);
238     }
239 
addToParent()240     private void addToParent() {
241         mParent.addView(mContainerView);
242 
243         // Why setting listener on parent? It turns out that if we force a relayout in the layout
244         // change listener of the view itself, the force layout flag will be reset to 0 when
245         // layout() returns. Therefore we have to do request layout on one level above the requested
246         // view.
247         mRootContentView.addOnLayoutChangeListener(mLayoutListener);
248     }
249 
250     // TODO(fgorski): Start using color ID, to remove the view from arguments.
getBackgroundColor(View view, Snackbar snackbar)251     private static int getBackgroundColor(View view, Snackbar snackbar) {
252         // Themes are used first.
253         if (snackbar.getTheme() == Snackbar.Theme.GOOGLE) {
254             return ApiCompatibilityUtils.getColor(
255                     view.getResources(), R.color.default_control_color_active);
256         }
257 
258         assert snackbar.getTheme() == Snackbar.Theme.BASIC;
259         if (snackbar.getBackgroundColor() != 0) {
260             return snackbar.getBackgroundColor();
261         }
262 
263         return ApiCompatibilityUtils.getColor(
264                 view.getResources(), R.color.snackbar_background_color);
265     }
266 
getTextAppearance(Snackbar snackbar)267     private static int getTextAppearance(Snackbar snackbar) {
268         if (snackbar.getTheme() == Snackbar.Theme.GOOGLE) {
269             return R.style.TextAppearance_TextMedium_Primary_Inverse;
270         }
271 
272         assert snackbar.getTheme() == Snackbar.Theme.BASIC;
273         if (snackbar.getTextAppearance() != 0) {
274             return snackbar.getTextAppearance();
275         }
276 
277         return R.style.TextAppearance_TextMedium_Primary;
278     }
279 
getButtonTextAppearance(Snackbar snackbar)280     private static int getButtonTextAppearance(Snackbar snackbar) {
281         if (snackbar.getTheme() == Snackbar.Theme.GOOGLE) {
282             return R.style.TextAppearance_Button_Text_Filled;
283         }
284 
285         assert snackbar.getTheme() == Snackbar.Theme.BASIC;
286         return R.style.TextButton;
287     }
288 
updateInternal(Snackbar snackbar, boolean animate)289     private boolean updateInternal(Snackbar snackbar, boolean animate) {
290         if (mSnackbar == snackbar) return false;
291         mSnackbar = snackbar;
292         mMessageView.setMaxLines(snackbar.getSingleLine() ? 1 : MAX_LINES);
293         mMessageView.setTemplate(snackbar.getTemplateText());
294         setViewText(mMessageView, snackbar.getText(), animate);
295 
296         ApiCompatibilityUtils.setTextAppearance(mMessageView, getTextAppearance(snackbar));
297         ApiCompatibilityUtils.setTextAppearance(
298                 mActionButtonView, getButtonTextAppearance(snackbar));
299 
300         int backgroundColor = getBackgroundColor(mContainerView, snackbar);
301         if (mIsTablet) {
302             // On tablet, snackbars have rounded corners.
303             mSnackbarView.setBackgroundResource(R.drawable.snackbar_background_tablet);
304             GradientDrawable backgroundDrawable =
305                     (GradientDrawable) mSnackbarView.getBackground().mutate();
306             backgroundDrawable.setColor(backgroundColor);
307         } else {
308             mSnackbarView.setBackgroundColor(backgroundColor);
309         }
310 
311         if (snackbar.getActionText() != null) {
312             mActionButtonView.setVisibility(View.VISIBLE);
313             mActionButtonView.setContentDescription(snackbar.getActionText());
314             setViewText(mActionButtonView, snackbar.getActionText(), animate);
315         } else {
316             mActionButtonView.setVisibility(View.GONE);
317         }
318         Drawable profileImage = snackbar.getProfileImage();
319         if (profileImage != null) {
320             mProfileImageView.setVisibility(View.VISIBLE);
321             mProfileImageView.setImageDrawable(profileImage);
322         } else {
323             mProfileImageView.setVisibility(View.GONE);
324         }
325 
326         if (mIsTablet) {
327             mContainerView.findViewById(R.id.snackbar_shadow_left).setVisibility(View.VISIBLE);
328             mContainerView.findViewById(R.id.snackbar_shadow_right).setVisibility(View.VISIBLE);
329         }
330 
331         return true;
332     }
333 
334     /**
335      * Starts the {@link Animator} with {@link SurfaceView} optimization disabled. If a
336      * {@link SurfaceView} is not present (mWindowAndroid is null), start the {@link Animator}
337      * in the normal way.
338      */
startAnimatorOnSurfaceView(Animator animator)339     private void startAnimatorOnSurfaceView(Animator animator) {
340         if (mWindowAndroid != null) {
341             mWindowAndroid.startAnimationOverContent(animator);
342         } else {
343             animator.start();
344         }
345     }
346 
getLayoutParams()347     private FrameLayout.LayoutParams getLayoutParams() {
348         return (FrameLayout.LayoutParams) mContainerView.getLayoutParams();
349     }
350 
setViewText(TextView view, CharSequence text, boolean animate)351     private void setViewText(TextView view, CharSequence text, boolean animate) {
352         if (view.getText().toString().equals(text)) return;
353         view.animate().cancel();
354         if (animate) {
355             view.setAlpha(0.0f);
356             view.setText(text);
357             view.animate().alpha(1.f).setDuration(mAnimationDuration).setListener(null);
358         } else {
359             view.setText(text);
360         }
361     }
362 }
363