blob: f47fbe0d149fab1bfb1c8b301e55376d7f44ee6c [file] [log] [blame]
Joshua Tsujib1a796b2019-01-16 15:43:12 -08001/*
2 * Copyright (C) 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.systemui.bubbles.animation;
18
Joshua Tsujib1a796b2019-01-16 15:43:12 -080019import android.content.res.Resources;
Joshua Tsujib1a796b2019-01-16 15:43:12 -080020import android.graphics.PointF;
21import android.graphics.RectF;
22import android.util.Log;
23import android.view.View;
24import android.view.WindowInsets;
Joshua Tsujib1a796b2019-01-16 15:43:12 -080025
26import androidx.dynamicanimation.animation.DynamicAnimation;
27import androidx.dynamicanimation.animation.FlingAnimation;
28import androidx.dynamicanimation.animation.FloatPropertyCompat;
29import androidx.dynamicanimation.animation.SpringAnimation;
30import androidx.dynamicanimation.animation.SpringForce;
31
32import com.android.systemui.R;
33
34import com.google.android.collect.Sets;
35
36import java.util.HashMap;
37import java.util.Set;
38
39/**
40 * Animation controller for bubbles when they're in their stacked state. Stacked bubbles sit atop
41 * each other with a slight offset to the left or right (depending on which side of the screen they
42 * are on). Bubbles 'follow' each other when dragged, and can be flung to the left or right sides of
43 * the screen.
44 */
45public class StackAnimationController extends
46 PhysicsAnimationLayout.PhysicsAnimationController {
47
48 private static final String TAG = "Bubbs.StackCtrl";
49
50 /** Scale factor to use initially for new bubbles being animated in. */
51 private static final float ANIMATE_IN_STARTING_SCALE = 1.15f;
52
Joshua Tsujia08b6d32019-01-29 16:15:52 -050053 /** Translation factor (multiplied by stack offset) to use for bubbles being animated in/out. */
54 private static final int ANIMATE_TRANSLATION_FACTOR = 4;
Joshua Tsujib1a796b2019-01-16 15:43:12 -080055
56 /**
57 * Values to use for the default {@link SpringForce} provided to the physics animation layout.
58 */
59 private static final float DEFAULT_STIFFNESS = 2500f;
60 private static final float DEFAULT_BOUNCINESS = 0.85f;
61
62 /**
63 * The canonical position of the stack. This is typically the position of the first bubble, but
64 * we need to keep track of it separately from the first bubble's translation in case there are
65 * no bubbles, or the first bubble was just added and being animated to its new position.
66 */
67 private PointF mStackPosition = new PointF();
68
Joshua Tsujia19515f2019-02-13 18:02:29 -050069 /** The height of the most recently visible IME. */
70 private float mImeHeight = 0f;
71
72 /**
73 * The Y position of the stack before the IME became visible, or {@link Float#MIN_VALUE} if the
74 * IME is not visible or the user moved the stack since the IME became visible.
75 */
76 private float mPreImeY = Float.MIN_VALUE;
77
Joshua Tsujib1a796b2019-01-16 15:43:12 -080078 /**
79 * Animations on the stack position itself, which would have been started in
80 * {@link #flingThenSpringFirstBubbleWithStackFollowing}. These animations dispatch to
81 * {@link #moveFirstBubbleWithStackFollowing} to move the entire stack (with 'following' effect)
82 * to a legal position on the side of the screen.
83 */
84 private HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mStackPositionAnimations =
85 new HashMap<>();
86
87 /** Horizontal offset of bubbles in the stack. */
88 private float mStackOffset;
89 /** Diameter of the bubbles themselves. */
90 private int mIndividualBubbleSize;
91 /** Size of spacing around the bubbles, separating it from the edge of the screen. */
92 private int mBubblePadding;
93 /** How far offscreen the stack rests. */
94 private int mBubbleOffscreen;
95 /** How far down the screen the stack starts, when there is no pre-existing location. */
96 private int mStackStartingVerticalOffset;
Joshua Tsujif44347f2019-02-12 14:28:06 -050097 /** Height of the status bar. */
98 private float mStatusBarHeight;
Joshua Tsujib1a796b2019-01-16 15:43:12 -080099
100 @Override
101 protected void setLayout(PhysicsAnimationLayout layout) {
102 super.setLayout(layout);
103
104 Resources res = layout.getResources();
105 mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
106 mIndividualBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
107 mBubblePadding = res.getDimensionPixelSize(R.dimen.bubble_padding);
108 mBubbleOffscreen = res.getDimensionPixelSize(R.dimen.bubble_stack_offscreen);
109 mStackStartingVerticalOffset =
110 res.getDimensionPixelSize(R.dimen.bubble_stack_starting_offset_y);
Joshua Tsujif44347f2019-02-12 14:28:06 -0500111 mStatusBarHeight =
112 res.getDimensionPixelSize(com.android.internal.R.dimen.status_bar_height);
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800113 }
114
115 /**
116 * Instantly move the first bubble to the given point, and animate the rest of the stack behind
117 * it with the 'following' effect.
118 */
119 public void moveFirstBubbleWithStackFollowing(float x, float y) {
Joshua Tsujia19515f2019-02-13 18:02:29 -0500120 // If we manually move the bubbles with the IME open, clear the return point since we don't
121 // want the stack to snap away from the new position.
122 mPreImeY = Float.MIN_VALUE;
123
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800124 moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X, x);
125 moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y, y);
126 }
127
128 /**
129 * The position of the stack - typically the position of the first bubble; if no bubbles have
130 * been added yet, it will be where the first bubble will go when added.
131 */
132 public PointF getStackPosition() {
133 return mStackPosition;
134 }
135
136 /**
137 * Flings the first bubble along the given property's axis, using the provided configuration
138 * values. When the animation ends - either by hitting the min/max, or by friction sufficiently
139 * reducing momentum - a SpringAnimation takes over to snap the bubble to the given final
140 * position.
141 */
142 public void flingThenSpringFirstBubbleWithStackFollowing(
143 DynamicAnimation.ViewProperty property,
144 float vel,
145 float friction,
146 SpringForce spring,
147 Float finalPosition) {
148 Log.d(TAG, String.format("Flinging %s.",
149 PhysicsAnimationLayout.getReadablePropertyName(property)));
150
151 StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
152 final float currentValue = firstBubbleProperty.getValue(this);
153 final RectF bounds = getAllowableStackPositionRegion();
154 final float min =
155 property.equals(DynamicAnimation.TRANSLATION_X)
156 ? bounds.left
157 : bounds.top;
158 final float max =
159 property.equals(DynamicAnimation.TRANSLATION_X)
160 ? bounds.right
161 : bounds.bottom;
162
163 FlingAnimation flingAnimation = new FlingAnimation(this, firstBubbleProperty);
164 flingAnimation.setFriction(friction)
165 .setStartVelocity(vel)
166
167 // If the bubble's property value starts beyond the desired min/max, use that value
168 // instead so that the animation won't immediately end. If, for example, the user
169 // drags the bubbles into the navigation bar, but then flings them upward, we want
170 // the fling to occur despite temporarily having a value outside of the min/max. If
171 // the bubbles are out of bounds and flung even farther out of bounds, the fling
172 // animation will halt immediately and the SpringAnimation will take over, springing
173 // it in reverse to the (legal) final position.
174 .setMinValue(Math.min(currentValue, min))
175 .setMaxValue(Math.max(currentValue, max))
176
177 .addEndListener((animation, canceled, endValue, endVelocity) -> {
178 if (!canceled) {
179 springFirstBubbleWithStackFollowing(property, spring, endVelocity,
180 finalPosition != null
181 ? finalPosition
182 : Math.max(min, Math.min(max, endValue)));
183 }
184 });
185
186 cancelStackPositionAnimation(property);
187 mStackPositionAnimations.put(property, flingAnimation);
188 flingAnimation.start();
189 }
190
191 /**
192 * Cancel any stack position animations that were started by calling
193 * @link #flingThenSpringFirstBubbleWithStackFollowing}, and remove any corresponding end
194 * listeners.
195 */
196 public void cancelStackPositionAnimations() {
197 cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_X);
198 cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_Y);
199
200 mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_X);
201 mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_Y);
202 }
203
204 /**
Joshua Tsujia19515f2019-02-13 18:02:29 -0500205 * Save the IME height so that the allowable stack bounds reflect the now-visible IME, and
206 * animate the stack out of the way if necessary.
207 */
208 public void updateBoundsForVisibleImeAndAnimate(int imeHeight) {
209 mImeHeight = imeHeight;
210
211 final float maxBubbleY = getAllowableStackPositionRegion().bottom;
212 if (mStackPosition.y > maxBubbleY && mPreImeY == Float.MIN_VALUE) {
213 mPreImeY = mStackPosition.y;
214
215 springFirstBubbleWithStackFollowing(
216 DynamicAnimation.TRANSLATION_Y,
217 getSpringForce(DynamicAnimation.TRANSLATION_Y, /* view */ null)
218 .setStiffness(SpringForce.STIFFNESS_LOW),
219 /* startVel */ 0f,
220 maxBubbleY);
221 }
222 }
223
224 /**
225 * Clear the IME height from the bounds and animate the stack back to its original position,
226 * assuming it wasn't moved in the meantime.
227 */
228 public void updateBoundsForInvisibleImeAndAnimate() {
229 mImeHeight = 0;
230
231 if (mPreImeY > Float.MIN_VALUE) {
232 springFirstBubbleWithStackFollowing(
233 DynamicAnimation.TRANSLATION_Y,
234 getSpringForce(DynamicAnimation.TRANSLATION_Y, /* view */ null)
235 .setStiffness(SpringForce.STIFFNESS_LOW),
236 /* startVel */ 0f,
237 mPreImeY);
238 mPreImeY = Float.MIN_VALUE;
239 }
240 }
241
242 /**
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800243 * Returns the region within which the stack is allowed to rest. This goes slightly off the left
244 * and right sides of the screen, below the status bar/cutout and above the navigation bar.
245 * While the stack is not allowed to rest outside of these bounds, it can temporarily be
246 * animated or dragged beyond them.
247 */
248 public RectF getAllowableStackPositionRegion() {
249 final WindowInsets insets = mLayout.getRootWindowInsets();
Joshua Tsujif44347f2019-02-12 14:28:06 -0500250 final RectF allowableRegion = new RectF();
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800251 if (insets != null) {
Joshua Tsujif44347f2019-02-12 14:28:06 -0500252 allowableRegion.left =
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800253 -mBubbleOffscreen
254 - mBubblePadding
255 + Math.max(
256 insets.getSystemWindowInsetLeft(),
Joshua Tsuji0fee7682019-01-25 11:37:49 -0500257 insets.getDisplayCutout() != null
258 ? insets.getDisplayCutout().getSafeInsetLeft()
259 : 0);
Joshua Tsujif44347f2019-02-12 14:28:06 -0500260 allowableRegion.right =
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800261 mLayout.getWidth()
262 - mIndividualBubbleSize
263 + mBubbleOffscreen
264 - mBubblePadding
265 - Math.max(
266 insets.getSystemWindowInsetRight(),
Joshua Tsuji0fee7682019-01-25 11:37:49 -0500267 insets.getDisplayCutout() != null
Joshua Tsujif44347f2019-02-12 14:28:06 -0500268 ? insets.getDisplayCutout().getSafeInsetRight()
269 : 0);
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800270
Joshua Tsujif44347f2019-02-12 14:28:06 -0500271 allowableRegion.top =
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800272 mBubblePadding
273 + Math.max(
Joshua Tsujif44347f2019-02-12 14:28:06 -0500274 mStatusBarHeight,
Joshua Tsuji0fee7682019-01-25 11:37:49 -0500275 insets.getDisplayCutout() != null
Joshua Tsujif44347f2019-02-12 14:28:06 -0500276 ? insets.getDisplayCutout().getSafeInsetTop()
277 : 0);
278 allowableRegion.bottom =
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800279 mLayout.getHeight()
280 - mIndividualBubbleSize
281 - mBubblePadding
Joshua Tsujia19515f2019-02-13 18:02:29 -0500282 - (mImeHeight > Float.MIN_VALUE ? mImeHeight + mBubblePadding : 0f)
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800283 - Math.max(
284 insets.getSystemWindowInsetBottom(),
Joshua Tsuji0fee7682019-01-25 11:37:49 -0500285 insets.getDisplayCutout() != null
286 ? insets.getDisplayCutout().getSafeInsetBottom()
287 : 0);
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800288 }
289
Joshua Tsujif44347f2019-02-12 14:28:06 -0500290 return allowableRegion;
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800291 }
292
293 @Override
294 Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
295 return Sets.newHashSet(
296 DynamicAnimation.TRANSLATION_X, // For positioning.
297 DynamicAnimation.TRANSLATION_Y,
298 DynamicAnimation.ALPHA, // For fading in new bubbles.
299 DynamicAnimation.SCALE_X, // For 'popping in' new bubbles.
300 DynamicAnimation.SCALE_Y);
301 }
302
303 @Override
304 int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
305 if (property.equals(DynamicAnimation.TRANSLATION_X)
306 || property.equals(DynamicAnimation.TRANSLATION_Y)) {
307 return index + 1; // Just chain them linearly.
308 } else {
309 return NONE;
310 }
311 }
312
313
314 @Override
315 float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
316 if (property.equals(DynamicAnimation.TRANSLATION_X)) {
317 // Offset to the left if we're on the left, or the right otherwise.
318 return mLayout.isFirstChildXLeftOfCenter(mStackPosition.x)
319 ? -mStackOffset : mStackOffset;
320 } else {
321 return 0f;
322 }
323 }
324
325 @Override
326 SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
327 return new SpringForce()
328 .setDampingRatio(DEFAULT_BOUNCINESS)
329 .setStiffness(DEFAULT_STIFFNESS);
330 }
331
332 @Override
333 void onChildAdded(View child, int index) {
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800334 if (mLayout.getChildCount() == 1) {
Joshua Tsujif44347f2019-02-12 14:28:06 -0500335 // If this is the first child added, position the stack in its starting position before
336 // animating in.
337 moveStackToStartPosition(() -> animateInBubble(child));
338 } else if (mLayout.indexOfChild(child) == 0) {
339 // Otherwise, animate the bubble in if it's the newest bubble. If we're adding a bubble
340 // to the back of the stack, it'll be largely invisible so don't bother animating it in.
341 animateInBubble(child);
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800342 }
343 }
344
345 @Override
Joshua Tsuji1575e6b2019-01-30 13:43:28 -0500346 void onChildRemoved(View child, int index, Runnable finishRemoval) {
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800347 // Animate the child out, actually removing it once its alpha is zero.
Joshua Tsujia08b6d32019-01-29 16:15:52 -0500348 mLayout.animateValueForChild(
Joshua Tsuji1575e6b2019-01-30 13:43:28 -0500349 DynamicAnimation.ALPHA, child, 0f, finishRemoval);
Joshua Tsujia08b6d32019-01-29 16:15:52 -0500350 mLayout.animateValueForChild(DynamicAnimation.SCALE_X, child, ANIMATE_IN_STARTING_SCALE);
351 mLayout.animateValueForChild(DynamicAnimation.SCALE_Y, child, ANIMATE_IN_STARTING_SCALE);
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800352
Joshua Tsujia08b6d32019-01-29 16:15:52 -0500353 // Animate the removing view in the opposite direction of the stack.
354 final float xOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
355 mLayout.animateValueForChild(DynamicAnimation.TRANSLATION_X, child,
356 mStackPosition.x - (-xOffset * ANIMATE_TRANSLATION_FACTOR));
357
358 // Pull the top of the stack to the correct position, the chained animations will instruct
359 // any children that are out of place to animate to the correct position.
360 mLayout.animateValueForChildAtIndex(DynamicAnimation.TRANSLATION_X, 0, mStackPosition.x);
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800361 }
362
363 /** Moves the stack, without any animation, to the starting position. */
Joshua Tsujif44347f2019-02-12 14:28:06 -0500364 private void moveStackToStartPosition(Runnable after) {
365 // Post to ensure that the layout's width and height have been calculated.
366 mLayout.post(() -> {
367 setStackPosition(
368 getAllowableStackPositionRegion().right,
369 getAllowableStackPositionRegion().top + mStackStartingVerticalOffset);
370 after.run();
371 });
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800372 }
373
374 /**
375 * Moves the first bubble instantly to the given X or Y translation, and instructs subsequent
376 * bubbles to animate 'following' to the new location.
377 */
378 private void moveFirstBubbleWithStackFollowing(
379 DynamicAnimation.ViewProperty property, float value) {
380
381 // Update the canonical stack position.
382 if (property.equals(DynamicAnimation.TRANSLATION_X)) {
383 mStackPosition.x = value;
384 } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
385 mStackPosition.y = value;
386 }
387
388 if (mLayout.getChildCount() > 0) {
389 property.setValue(mLayout.getChildAt(0), value);
390 mLayout.animateValueForChildAtIndex(
391 property,
392 /* index */ 1,
393 value + getOffsetForChainedPropertyAnimation(property));
394 }
395 }
396
397 /** Moves the stack to a position instantly, with no animation. */
398 private void setStackPosition(float x, float y) {
399 Log.d(TAG, String.format("Setting position to (%f, %f).", x, y));
400 mStackPosition.set(x, y);
401
402 cancelStackPositionAnimations();
403
404 // Since we're not using the chained animations, apply the offsets manually.
405 final float xOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
406 final float yOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_Y);
407 for (int i = 0; i < mLayout.getChildCount(); i++) {
408 mLayout.getChildAt(i).setTranslationX(x + (i * xOffset));
409 mLayout.getChildAt(i).setTranslationY(y + (i * yOffset));
410 }
411 }
412
Joshua Tsujif44347f2019-02-12 14:28:06 -0500413 /** Animates in the given bubble. */
414 private void animateInBubble(View child) {
415 child.setTranslationY(mStackPosition.y);
416
417 // Pop in the new bubble.
418 child.setScaleX(ANIMATE_IN_STARTING_SCALE);
419 child.setScaleY(ANIMATE_IN_STARTING_SCALE);
420 mLayout.animateValueForChildAtIndex(DynamicAnimation.SCALE_X, 0, 1f);
421 mLayout.animateValueForChildAtIndex(DynamicAnimation.SCALE_Y, 0, 1f);
422
423 // Fade in the new bubble.
424 child.setAlpha(0);
425 mLayout.animateValueForChildAtIndex(DynamicAnimation.ALPHA, 0, 1f);
426
427 // Start the new bubble 4x the normal offset distance in the opposite direction. We'll
428 // animate in from this position. Since the animations are chained, when the new bubble
429 // flies in from the side, it will push the other ones out of the way.
430 float xOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
431 child.setTranslationX(mStackPosition.x - ANIMATE_TRANSLATION_FACTOR * xOffset);
432 mLayout.animateValueForChildAtIndex(
433 DynamicAnimation.TRANSLATION_X, 0, mStackPosition.x);
434 }
435
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800436 /**
437 * Springs the first bubble to the given final position, with the rest of the stack 'following'.
438 */
439 private void springFirstBubbleWithStackFollowing(
440 DynamicAnimation.ViewProperty property, SpringForce spring,
441 float vel, float finalPosition) {
442
443 Log.d(TAG, String.format("Springing %s to final position %f.",
Joshua Tsujia19515f2019-02-13 18:02:29 -0500444 PhysicsAnimationLayout.getReadablePropertyName(property),
445 finalPosition));
Joshua Tsujib1a796b2019-01-16 15:43:12 -0800446
447 StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
448 SpringAnimation springAnimation =
449 new SpringAnimation(this, firstBubbleProperty)
450 .setSpring(spring)
451 .setStartVelocity(vel);
452
453 cancelStackPositionAnimation(property);
454 mStackPositionAnimations.put(property, springAnimation);
455 springAnimation.animateToFinalPosition(finalPosition);
456 }
457
458 /**
459 * Cancels any outstanding first bubble property animations that are running. This does not
460 * affect the SpringAnimations controlling the individual bubbles' 'following' effect - it only
461 * cancels animations started from {@link #springFirstBubbleWithStackFollowing} and
462 * {@link #flingThenSpringFirstBubbleWithStackFollowing}.
463 */
464 private void cancelStackPositionAnimation(DynamicAnimation.ViewProperty property) {
465 if (mStackPositionAnimations.containsKey(property)) {
466 mStackPositionAnimations.get(property).cancel();
467 }
468 }
469
470 /**
471 * FloatProperty that uses {@link #moveFirstBubbleWithStackFollowing} to set the first bubble's
472 * translation and animate the rest of the stack with it. A DynamicAnimation can animate this
473 * property directly to move the first bubble and cause the stack to 'follow' to the new
474 * location.
475 *
476 * This could also be achieved by simply animating the first bubble view and adding an update
477 * listener to dispatch movement to the rest of the stack. However, this would require
478 * duplication of logic in that update handler - it's simpler to keep all logic contained in the
479 * {@link #moveFirstBubbleWithStackFollowing} method.
480 */
481 private class StackPositionProperty
482 extends FloatPropertyCompat<StackAnimationController> {
483 private final DynamicAnimation.ViewProperty mProperty;
484
485 private StackPositionProperty(DynamicAnimation.ViewProperty property) {
486 super(property.toString());
487 mProperty = property;
488 }
489
490 @Override
491 public float getValue(StackAnimationController controller) {
492 return mProperty.getValue(mLayout.getChildAt(0));
493 }
494
495 @Override
496 public void setValue(StackAnimationController controller, float value) {
497 moveFirstBubbleWithStackFollowing(mProperty, value);
498 }
499 }
500}
501