content: /*
   * Copyright (C) 2017 The Android Open Source Project
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  
  package android.widget;
  
  import static java.lang.annotation.RetentionPolicy.SOURCE;
  
  import android.animation.Animator;
  import android.animation.AnimatorSet;
  import android.animation.ObjectAnimator;
  import android.animation.ValueAnimator;
  import android.annotation.ColorInt;
  import android.annotation.FloatRange;
  import android.annotation.IntDef;
  import android.content.Context;
  import android.graphics.Canvas;
  import android.graphics.Paint;
  import android.graphics.Path;
  import android.graphics.PointF;
  import android.graphics.RectF;
  import android.graphics.drawable.Drawable;
  import android.graphics.drawable.ShapeDrawable;
  import android.graphics.drawable.shapes.Shape;
  import android.text.Layout;
  import android.view.animation.AnimationUtils;
  import android.view.animation.Interpolator;
  
  import com.android.internal.util.Preconditions;
  
  import java.lang.annotation.Retention;
  import java.util.ArrayList;
  import java.util.Collections;
  import java.util.Comparator;
  import java.util.List;
  
  /**
   * A utility class for creating and animating the Smart Select animation.
   */
  final class SmartSelectSprite {
  
      private static final int EXPAND_DURATION = 300;
      private static final int CORNER_DURATION = 50;
  
      private final Interpolator mExpandInterpolator;
      private final Interpolator mCornerInterpolator;
  
      private Animator mActiveAnimator = null;
      private final Runnable mInvalidator;
      @ColorInt
      private final int mFillColor;
  
      static final Comparator<RectF> RECTANGLE_COMPARATOR = Comparator
              .<RectF>comparingDouble(e -> e.bottom)
              .thenComparingDouble(e -> e.left);
  
      private Drawable mExistingDrawable = null;
      private RectangleList mExistingRectangleList = null;
  
      static final class RectangleWithTextSelectionLayout {
          private final RectF mRectangle;
          @Layout.TextSelectionLayout
          private final int mTextSelectionLayout;
  
          RectangleWithTextSelectionLayout(RectF rectangle, int textSelectionLayout) {
              mRectangle = Preconditions.checkNotNull(rectangle);
              mTextSelectionLayout = textSelectionLayout;
          }
  
          public RectF getRectangle() {
              return mRectangle;
          }
  
          @Layout.TextSelectionLayout
          public int getTextSelectionLayout() {
              return mTextSelectionLayout;
          }
      }
  
      /**
       * A rounded rectangle with a configurable corner radius and the ability to expand outside of
       * its bounding rectangle and clip against it.
       */
      private static final class RoundedRectangleShape extends Shape {
  
          private static final String PROPERTY_ROUND_RATIO = "roundRatio";
  
          /**
           * The direction in which the rectangle will perform its expansion. A rectangle can expand
           * from its left edge, its right edge or from the center (or, more precisely, the user's
           * touch point). For example, in left-to-right text, a selection spanning two lines with the
           * user's action being on the first line will have the top rectangle and expansion direction
           * of CENTER, while the bottom one will have an expansion direction of RIGHT.
           */
          @Retention(SOURCE)
          @IntDef({ExpansionDirection.LEFT, ExpansionDirection.CENTER, ExpansionDirection.RIGHT})
          private @interface ExpansionDirection {
              int LEFT = -1;
              int CENTER = 0;
              int RIGHT = 1;
          }
  
          private static @ExpansionDirection int invert(@ExpansionDirection int expansionDirection) {
              return expansionDirection * -1;
          }
  
          private final RectF mBoundingRectangle;
          private float mRoundRatio = 1.0f;
          private final @ExpansionDirection int mExpansionDirection;
  
          private final RectF mDrawRect = new RectF();
          private final Path mClipPath = new Path();
  
          /** How offset the left edge of the rectangle is from the left side of the bounding box. */
          private float mLeftBoundary = 0;
          /** How offset the right edge of the rectangle is from the left side of the bounding box. */
          private float mRightBoundary = 0;
  
          /** Whether the horizontal bounds are inverted (for RTL scenarios). */
          private final boolean mInverted;
  
          private final float mBoundingWidth;
  
          private RoundedRectangleShape(
                  final RectF boundingRectangle,
                  final @ExpansionDirection int expansionDirection,
                  final boolean inverted) {
              mBoundingRectangle = new RectF(boundingRectangle);
              mBoundingWidth = boundingRectangle.width();
              mInverted = inverted && expansionDirection != ExpansionDirection.CENTER;
  
              if (inverted) {
                  mExpansionDirection = invert(expansionDirection);
              } else {
                  mExpansionDirection = expansionDirection;
              }
  
              if (boundingRectangle.height() > boundingRectangle.width()) {
                  setRoundRatio(0.0f);
              } else {
                  setRoundRatio(1.0f);
              }
          }
  
          /*
           * In order to achieve the "rounded rectangle hits the wall" effect, we draw an expanding
           * rounded rectangle that is clipped by the bounding box of the selected text.
           */
          @Override
          public void draw(Canvas canvas, Paint paint) {
              if (mLeftBoundary == mRightBoundary) {
                  return;
              }
  
              final float cornerRadius = getCornerRadius();
              final float adjustedCornerRadius = getAdjustedCornerRadius();
  
              mDrawRect.set(mBoundingRectangle);
              mDrawRect.left = mBoundingRectangle.left + mLeftBoundary - cornerRadius / 2;
              mDrawRect.right = mBoundingRectangle.left + mRightBoundary + cornerRadius / 2;
  
              canvas.save();
              mClipPath.reset();
              mClipPath.addRoundRect(
                      mDrawRect,
                      adjustedCornerRadius,
                      adjustedCornerRadius,
                      Path.Direction.CW);
              canvas.clipPath(mClipPath);
              canvas.drawRect(mBoundingRectangle, paint);
              canvas.restore();
          }
  
          void setRoundRatio(@FloatRange(from = 0.0, to = 1.0) final float roundRatio) {
              mRoundRatio = roundRatio;
          }
  
          float getRoundRatio() {
              return mRoundRatio;
          }
  
          private void setStartBoundary(final float startBoundary) {
              if (mInverted) {
                  mRightBoundary = mBoundingWidth - startBoundary;
              } else {
                  mLeftBoundary = startBoundary;
              }
          }
  
          private void setEndBoundary(final float endBoundary) {
              if (mInverted) {
                  mLeftBoundary = mBoundingWidth - endBoundary;
              } else {
                  mRightBoundary = endBoundary;
              }
          }
  
          private float getCornerRadius() {
              return Math.min(mBoundingRectangle.width(), mBoundingRectangle.height());
          }
  
          private float getAdjustedCornerRadius() {
              return (getCornerRadius() * mRoundRatio);
          }
  
          private float getBoundingWidth() {
              return (int) (mBoundingRectangle.width() + getCornerRadius());
          }
  
      }
  
      /**
       * A collection of {@link RoundedRectangleShape}s that abstracts them to a single shape whose
       * collective left and right boundary can be manipulated.
       */
      private static final class RectangleList extends Shape {
  
          @Retention(SOURCE)
          @IntDef({DisplayType.RECTANGLES, DisplayType.POLYGON})
          private @interface DisplayType {
              int RECTANGLES = 0;
              int POLYGON = 1;
          }
  
          private static final String PROPERTY_RIGHT_BOUNDARY = "rightBoundary";
          private static final String PROPERTY_LEFT_BOUNDARY = "leftBoundary";
  
          private final List<RoundedRectangleShape> mRectangles;
          private final List<RoundedRectangleShape> mReversedRectangles;
  
          private final Path mOutlinePolygonPath;
          private @DisplayType int mDisplayType = DisplayType.RECTANGLES;
  
          private RectangleList(final List<RoundedRectangleShape> rectangles) {
              mRectangles = new ArrayList<>(rectangles);
              mReversedRectangles = new ArrayList<>(rectangles);
              Collections.reverse(mReversedRectangles);
              mOutlinePolygonPath = generateOutlinePolygonPath(rectangles);
          }
  
          private void setLeftBoundary(final float leftBoundary) {
              float boundarySoFar = getTotalWidth();
              for (RoundedRectangleShape rectangle : mReversedRectangles) {
                  final float rectangleLeftBoundary = boundarySoFar - rectangle.getBoundingWidth();
                  if (leftBoundary < rectangleLeftBoundary) {
                      rectangle.setStartBoundary(0);
                  } else if (leftBoundary > boundarySoFar) {
                      rectangle.setStartBoundary(rectangle.getBoundingWidth());
                  } else {
                      rectangle.setStartBoundary(
                              rectangle.getBoundingWidth() - boundarySoFar + leftBoundary);
                  }
  
                  boundarySoFar = rectangleLeftBoundary;
              }
          }
  
          private void setRightBoundary(final float rightBoundary) {
              float boundarySoFar = 0;
              for (RoundedRectangleShape rectangle : mRectangles) {
                  final float rectangleRightBoundary = rectangle.getBoundingWidth() + boundarySoFar;
                  if (rectangleRightBoundary < rightBoundary) {
                      rectangle.setEndBoundary(rectangle.getBoundingWidth());
                  } else if (boundarySoFar > rightBoundary) {
                      rectangle.setEndBoundary(0);
                  } else {
                      rectangle.setEndBoundary(rightBoundary - boundarySoFar);
                  }
  
                  boundarySoFar = rectangleRightBoundary;
              }
          }
  
          void setDisplayType(@DisplayType int displayType) {
              mDisplayType = displayType;
          }
  
          private int getTotalWidth() {
              int sum = 0;
              for (RoundedRectangleShape rectangle : mRectangles) {
                  sum += rectangle.getBoundingWidth();
              }
              return sum;
          }
  
          @Override
          public void draw(Canvas canvas, Paint paint) {
              if (mDisplayType == DisplayType.POLYGON) {
                  drawPolygon(canvas, paint);
              } else {
                  drawRectangles(canvas, paint);
              }
          }
  
          private void drawRectangles(final Canvas canvas, final Paint paint) {
              for (RoundedRectangleShape rectangle : mRectangles) {
                  rectangle.draw(canvas, paint);
              }
          }
  
          private void drawPolygon(final Canvas canvas, final Paint paint) {
              canvas.drawPath(mOutlinePolygonPath, paint);
          }
  
          private static Path generateOutlinePolygonPath(
                  final List<RoundedRectangleShape> rectangles) {
              final Path path = new Path();
              for (final RoundedRectangleShape shape : rectangles) {
                  final Path rectanglePath = new Path();
                  rectanglePath.addRect(shape.mBoundingRectangle, Path.Direction.CW);
                  path.op(rectanglePath, Path.Op.UNION);
              }
              return path;
          }
  
      }
  
      /**
       * @param context the {@link Context} in which the animation will run
       * @param highlightColor the highlight color of the underlying {@link TextView}
       * @param invalidator a {@link Runnable} which will be called every time the animation updates,
       *                    indicating that the view drawing the animation should invalidate itself
       */
      SmartSelectSprite(final Context context, @ColorInt int highlightColor,
              final Runnable invalidator) {
          mExpandInterpolator = AnimationUtils.loadInterpolator(
                  context,
                  android.R.interpolator.fast_out_slow_in);
          mCornerInterpolator = AnimationUtils.loadInterpolator(
                  context,
                  android.R.interpolator.fast_out_linear_in);
          mFillColor = highlightColor;
          mInvalidator = Preconditions.checkNotNull(invalidator);
      }
  
      /**
       * Performs the Smart Select animation on the view bound to this SmartSelectSprite.
       *
       * @param start                 The point from which the animation will start. Must be inside
       *                              destinationRectangles.
       * @param destinationRectangles The rectangles which the animation will fill out by its
       *                              "selection" and finally join them into a single polygon. In
       *                              order to get the correct visual behavior, these rectangles
       *                              should be sorted according to {@link #RECTANGLE_COMPARATOR}.
       * @param onAnimationEnd        the callback which will be invoked once the whole animation
       *                              completes
       * @throws IllegalArgumentException if the given start point is not in any of the
       *                                  destinationRectangles
       * @see #cancelAnimation()
       */
      // TODO nullability checks on parameters
      public void startAnimation(
              final PointF start,
              final List<RectangleWithTextSelectionLayout> destinationRectangles,
              final Runnable onAnimationEnd) {
          cancelAnimation();
  
          final ValueAnimator.AnimatorUpdateListener updateListener =
                  valueAnimator -> mInvalidator.run();
  
          final int rectangleCount = destinationRectangles.size();
  
          final List<RoundedRectangleShape> shapes = new ArrayList<>(rectangleCount);
          final List<Animator> cornerAnimators = new ArrayList<>(rectangleCount);
  
          RectangleWithTextSelectionLayout centerRectangle = null;
  
          int startingOffset = 0;
          for (RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout :
                  destinationRectangles) {
              final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle();
              if (contains(rectangle, start)) {
                  centerRectangle = rectangleWithTextSelectionLayout;
                  break;
              }
              startingOffset += rectangle.width();
          }
  
          if (centerRectangle == null) {
              throw new IllegalArgumentException("Center point is not inside any of the rectangles!");
          }
  
          startingOffset += start.x - centerRectangle.getRectangle().left;
  
          final @RoundedRectangleShape.ExpansionDirection int[] expansionDirections =
                  generateDirections(centerRectangle, destinationRectangles);
  
          for (int index = 0; index < rectangleCount; ++index) {
              final RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout =
                      destinationRectangles.get(index);
              final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle();
              final RoundedRectangleShape shape = new RoundedRectangleShape(
                      rectangle,
                      expansionDirections[index],
                      rectangleWithTextSelectionLayout.getTextSelectionLayout()
                              == Layout.TEXT_SELECTION_LAYOUT_RIGHT_TO_LEFT);
              cornerAnimators.add(createCornerAnimator(shape, updateListener));
              shapes.add(shape);
          }
  
          final RectangleList rectangleList = new RectangleList(shapes);
          final ShapeDrawable shapeDrawable = new ShapeDrawable(rectangleList);
  
          final Paint paint = shapeDrawable.getPaint();
          paint.setColor(mFillColor);
          paint.setStyle(Paint.Style.FILL);
  
          mExistingRectangleList = rectangleList;
          mExistingDrawable = shapeDrawable;
  
          mActiveAnimator = createAnimator(rectangleList, startingOffset, startingOffset,
                  cornerAnimators, updateListener, onAnimationEnd);
          mActiveAnimator.start();
      }
  
      /** Returns whether the sprite is currently animating. */
      public boolean isAnimationActive() {
          return mActiveAnimator != null && mActiveAnimator.isRunning();
      }
  
      private Animator createAnimator(
              final RectangleList rectangleList,
              final float startingOffsetLeft,
              final float startingOffsetRight,
              final List<Animator> cornerAnimators,
              final ValueAnimator.AnimatorUpdateListener updateListener,
              final Runnable onAnimationEnd) {
          final ObjectAnimator rightBoundaryAnimator = ObjectAnimator.ofFloat(
                  rectangleList,
                  RectangleList.PROPERTY_RIGHT_BOUNDARY,
                  startingOffsetRight,
                  rectangleList.getTotalWidth());
  
          final ObjectAnimator leftBoundaryAnimator = ObjectAnimator.ofFloat(
                  rectangleList,
                  RectangleList.PROPERTY_LEFT_BOUNDARY,
                  startingOffsetLeft,
                  0);
  
          rightBoundaryAnimator.setDuration(EXPAND_DURATION);
          leftBoundaryAnimator.setDuration(EXPAND_DURATION);
  
          rightBoundaryAnimator.addUpdateListener(updateListener);
          leftBoundaryAnimator.addUpdateListener(updateListener);
  
          rightBoundaryAnimator.setInterpolator(mExpandInterpolator);
          leftBoundaryAnimator.setInterpolator(mExpandInterpolator);
  
          final AnimatorSet cornerAnimator = new AnimatorSet();
          cornerAnimator.playTogether(cornerAnimators);
  
          final AnimatorSet boundaryAnimator = new AnimatorSet();
          boundaryAnimator.playTogether(leftBoundaryAnimator, rightBoundaryAnimator);
  
          final AnimatorSet animatorSet = new AnimatorSet();
          animatorSet.playSequentially(boundaryAnimator, cornerAnimator);
  
          setUpAnimatorListener(animatorSet, onAnimationEnd);
  
          return animatorSet;
      }
  
      private void setUpAnimatorListener(final Animator animator, final Runnable onAnimationEnd) {
          animator.addListener(new Animator.AnimatorListener() {
              @Override
              public void onAnimationStart(Animator animator) {
              }
  
              @Override
              public void onAnimationEnd(Animator animator) {
                  mExistingRectangleList.setDisplayType(RectangleList.DisplayType.POLYGON);
                  mInvalidator.run();
  
                  onAnimationEnd.run();
              }
  
              @Override
              public void onAnimationCancel(Animator animator) {
              }
  
              @Override
              public void onAnimationRepeat(Animator animator) {
              }
          });
      }
  
      private ObjectAnimator createCornerAnimator(
              final RoundedRectangleShape shape,
              final ValueAnimator.AnimatorUpdateListener listener) {
          final ObjectAnimator animator = ObjectAnimator.ofFloat(
                  shape,
                  RoundedRectangleShape.PROPERTY_ROUND_RATIO,
                  shape.getRoundRatio(), 0.0F);
          animator.setDuration(CORNER_DURATION);
          animator.addUpdateListener(listener);
          animator.setInterpolator(mCornerInterpolator);
          return animator;
      }
  
      private static @RoundedRectangleShape.ExpansionDirection int[] generateDirections(
              final RectangleWithTextSelectionLayout centerRectangle,
              final List<RectangleWithTextSelectionLayout> rectangles) {
          final @RoundedRectangleShape.ExpansionDirection int[] result = new int[rectangles.size()];
  
          final int centerRectangleIndex = rectangles.indexOf(centerRectangle);
  
          for (int i = 0; i < centerRectangleIndex - 1; ++i) {
              result[i] = RoundedRectangleShape.ExpansionDirection.LEFT;
          }
  
          if (rectangles.size() == 1) {
              result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER;
          } else if (centerRectangleIndex == 0) {
              result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.LEFT;
          } else if (centerRectangleIndex == rectangles.size() - 1) {
              result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.RIGHT;
          } else {
              result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER;
          }
  
          for (int i = centerRectangleIndex + 1; i < result.length; ++i) {
              result[i] = RoundedRectangleShape.ExpansionDirection.RIGHT;
          }
  
          return result;
      }
  
      /**
       * A variant of {@link RectF#contains(float, float)} that also allows the point to reside on
       * the right boundary of the rectangle.
       *
       * @param rectangle the rectangle inside which the point should be to be considered "contained"
       * @param point     the point which will be tested
       * @return whether the point is inside the rectangle (or on it's right boundary)
       */
      private static boolean contains(final RectF rectangle, final PointF point) {
          final float x = point.x;
          final float y = point.y;
          return x >= rectangle.left && x <= rectangle.right && y >= rectangle.top
                  && y <= rectangle.bottom;
      }
  
      private void removeExistingDrawables() {
          mExistingDrawable = null;
          mExistingRectangleList = null;
          mInvalidator.run();
      }
  
      /**
       * Cancels any active Smart Select animation that might be in progress.
       */
      public void cancelAnimation() {
          if (mActiveAnimator != null) {
              mActiveAnimator.cancel();
              mActiveAnimator = null;
              removeExistingDrawables();
          }
      }
  
      public void draw(Canvas canvas) {
          if (mExistingDrawable != null) {
              mExistingDrawable.draw(canvas);
          }
      }
  
  }
  