English | 简体中文 | 繁體中文 | Русский язык | Français | Español | Português | Deutsch | 日本語 | 한국어 | Italiano | بالعربية

Реализация эффекта качания маятника для индикатора прогресса PendulumView с помощью Android自定义 View

В Интернете нашёл компонент iOS PendulumView, который реализует анимацию маятника. Поскольку стандартные индикаторы прогресса действительно не очень привлекательны, хочу предложить создать пользовательский View для достижения такого эффекта, который можно будет использовать в индикаторах прогресса загрузки страницы. 

Не будем говорить лишнее, сначала посмотрим效果图

 

Черная нижняя кайма - это неаккуратное занесение при записи, её можно пропустить. 

Поскольку это пользовательский View, мы следуем стандартному процессу:第一步 - определить пользовательские свойства 

Свойства, определяемые пользователем 

Создание файла свойств 

В Android-проекте создайте файл attrs.xml в директории res->values, содержимое файла такое:

 <?xml version="1.0" encoding="utf-8"?>
<resources>
 <declare-styleable name="PendulumView">
  <attr name="globeNum" format="integer"/>
  <attr name="globeColor" format="color"/>
  <attr name="globeRadius" format="dimension"/>
  <attr name="swingRadius" format="dimension"/>
 </declare-styleable>
</resources>

Свойство name в declare-styleable используется для ссылки на этот файл свойств в коде. В большинстве случаев name указывает на имя нашего пользовательского класса View, что является интуитивно понятным.

Использование styleale позволяет системе выполнить много операций (массивы int[], константы индексов) и упростить наш процесс разработки, например, в приведенном ниже коде используется R.styleable.PendulumView_golbeNum, которые автоматически генерируются системой. 

Свойство globeNum означает количество шаров, globeColor означает цвет шаров, globeRadius означает半径 шара, swingRadius означает半径 колебания 

Чтение значений свойств 

В конструкторе пользовательского view через TypedArray читаются значения свойств 

Through AttributeSet можно получить значения свойств, но если значение свойства является типом ссылке, то получаем только ID, и необходимо продолжить через парсинг ID для получения真正的 значения свойства, а TypedArray напрямую помогает нам выполнить это. 

public PendulumView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    //Использование TypedArray для чтения пользовательских значений свойств
    TypedArray ta = context.getResources().obtainAttributes(attrs, R.styleable.PendulumView);
    int count = ta.getIndexCount();
    for (int i = 0; i < count; i++) {
      int attr = ta.getIndex(i);
      switch (attr) {
        case R.styleable.PendulumView_globeNum:
          mGlobeNum = ta.getInt(attr, 5);
          break;
        case R.styleable.PendulumView_globeRadius:
          mGlobeRadius = ta.getDimensionPixelSize(attr, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, 16, getResources().getDisplayMetrics()));
          break;
        case R.styleable.PendulumView_globeColor:
          mGlobeColor = ta.getColor(attr, Color.BLUE);
          break;
        case R.styleable.PendulumView_swingRadius:
          mSwingRadius = ta.getDimensionPixelSize(attr, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, 16, getResources().getDisplayMetrics()));
          break;
      }
    }
    ta.recycle(); //избегать проблем при следующем чтении
    mPaint = new Paint();
    mPaint.setColor(mGlobeColor);
  }

переписать метод OnMeasure() 

@Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    //高度为小球半径+摆动半径
    int height = mGlobeRadius + mSwingRadius;
    //宽度为2*摆动半径+(小球数量-1)*小球直径
    int width = mSwingRadius + mGlobeRadius * 2 * (mGlobeNum - 1) + mSwingRadius;
    //如果测量模式为EXACTLY,则直接使用推荐值,如不为EXACTLY(一般处理wrap_content情况),使用自己计算的宽高
    setMeasuredDimension((widthMode == MeasureSpec.EXACTLY) ? widthSize : width, (heightMode == MeasureSpec.EXACTLY) ? heightSize : height);
  }

其中
 int height = mGlobeRadius + mSwingRadius;
<pre name="code" class="java">int width = mSwingRadius + mGlobeRadius * 2 * (mGlobeNum - 1) + mSwingRadius;
用于处理测量模式为AT_MOST的情况,一般是自定义View的宽高设置为了wrap_content,此时通过小球的数量,半径,摆动的半径等计算View的宽高,如下图: 

以小球个数5为例,View的大小为下图红色矩形区域 

重写onDraw()方法 

@Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //绘制除左右两个小球外的其他小球
    for (int i = 0; i < mGlobeNum - 2; i++) {
      canvas.drawCircle(mSwingRadius + (i + 1) * 2 * mGlobeRadius, mSwingRadius, mGlobeRadius, mPaint);
    }
    if (mLeftPoint == null || mRightPoint == null) {
      //初始化最左右两小球坐标
      mLeftPoint = new Point(mSwingRadius, mSwingRadius);
      mRightPoint = new Point(mSwingRadius + mGlobeRadius * 2 * (mGlobeNum - 1), mSwingRadius);
      //Запуск анимации колебания
      startPendulumAnimation();
    }
    //Рисование левых и правых шариков
    canvas.drawCircle(mLeftPoint.x, mLeftPoint.y, mGlobeRadius, mPaint);
    canvas.drawCircle(mRightPoint.x, mRightPoint.y, mGlobeRadius, mPaint);
  }

Метод onDraw() является ключом к созданию пользовательского View, в этом методе рисуется видимость View. Код сначала рисует все шарики, кроме крайних левых и правых шариков, затем проверяет координаты левых и правых шариков. Если это первый раз, то координаты пусты, инициализируются координаты двух шариков и запускается анимация. В конце через значения x, y mLeftPoint и mRightPoint рисуются два шарика. 

mLeftPoint и mRightPoint являются объектами android.graphics.Point, они используются только для хранения координат x, y левых и правых шариков. 

Использование анимации свойств 

public void startPendulumAnimation() {
    //Использование анимации свойств
    final ValueAnimator anim = ValueAnimator.ofObject(new TypeEvaluator() {
      @Override
      public Object evaluate(float fraction, Object startValue, Object endValue) {
        //参数fraction用于表示动画的完成度,我们根据它来计算当前的动画值
        double angle = Math.toRadians(90 * fraction);
        int x = (int) ((mSwingRadius - mGlobeRadius) * Math.sin(angle));
        int y = (int) ((mSwingRadius - mGlobeRadius) * Math.cos(angle));
        Point point = new Point(x, y);
        return point;
      }
    }, new Point(), new Point());
    anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        Point point = (Point) animation.getAnimatedValue();
        //Получение текущего значения fraction
        float fraction = anim.getAnimatedFraction();
        //Проверяем, уменьшался ли fraction, а затем увеличился, то есть проверяем, находится ли шар в состоянии подъема
        //Переключаем шар перед каждым поднятием
        if (lastSlope && fraction > mLastFraction) {
          isNext = !isNext;
        }
        //Через постоянное изменение координат x, y левых и правых шариков достигается анимационный эффект
        //Использование isNext для определения, должен ли двигаться левый шарик или правый шарик
        if (isNext) {
          //Когда левый шарик колеблется, правый шарик находится в начальной позиции
          mRightPoint.x = mSwingRadius + mGlobeRadius * 2 * (mGlobeNum - 1);
          mRightPoint.y = mSwingRadius;
          mLeftPoint.x = mSwingRadius - point.x;
          mLeftPoint.y = mGlobeRadius + point.y;
        } else {
          //当右边小球摆动时,左边小球置于初始位置
          mLeftPoint.x = mSwingRadius;
          mRightPoint.y = mSwingRadius;
          mRightPoint.x = mSwingRadius + (mGlobeNum - 1) * mGlobeRadius * 2 + point.x;
          mRightPoint.y = mGlobeRadius + point.y;
        }
        invalidate();
        lastSlope = fraction < mLastFraction;
        mLastFraction = fraction;
      }
    });
    //设置永久循环播放
    anim.setRepeatCount(ValueAnimator.INFINITE);
    //设置循环模式为倒序播放
    anim.setRepeatMode(ValueAnimator.REVERSE);
    anim.setDuration(200);
    //Настройка interpolатора, контроль скорости изменения анимации
    anim.setInterpolator(new DecelerateInterpolator());
    anim.start();
  }

 其中使用ValueAnimator.ofObject方法是为了可以对Point对象进行操作,更为形象具体。还有就是通过ofObject方法使用了自定义的TypeEvaluator对象,由此得到了fraction值,该值是一个从0-1变化的小数。所以该方法的后两个参数startValue(new Point()),endValue(new Point())并没有实际意义,也可以直接不写,此处写上主要是为了便于理解。同样道理也可以直接使用ValueAnimator.ofFloat(0f, 1f)方法获取到一个从0-1变化的小数。

     final ValueAnimator anim = ValueAnimator.ofObject(new TypeEvaluator() {
      @Override
      public Object evaluate(float fraction, Object startValue, Object endValue) {
        //参数fraction用于表示动画的完成度,我们根据它来计算当前的动画值
        double angle = Math.toRadians(90 * fraction);
        int x = (int) ((mSwingRadius - mGlobeRadius) * Math.sin(angle));
        int y = (int) ((mSwingRadius - mGlobeRadius) * Math.cos(angle));
        Point point = new Point(x, y);
        return point;
      }
    }, new Point(), new Point());

Через fraction мы вычисляем значение изменения угла движения шара, от 0 до 90 градусов

 

Значение mSwingRadius - mGlobeRadius представляет собой длину зеленой линии на рисунке, линия движения, линия центра движения шара - это дуга с半径ом (mSwingRadius - mGlobeRadius), изменяющийся значение X - (mSwingRadius - mGlobeRadius) * sin(angle), изменяющееся значение Y - (mSwingRadius - mGlobeRadius) * cos(angle) 

Координаты центра движения соответствующего шара (mSwingRadius - x, mGlobeRadius + y) 

Реальные координаты центра движения правого шара (mSwingRadius + (mGlobeNum - 1) * mGlobeRadius * 2 + x, mGlobeRadius + y) 

Угол координаты вертикали слева и справа одинаковый, а координата горизонтали различается. 

        float fraction = anim.getAnimatedFraction();
        //Проверяем, уменьшался ли fraction, а затем увеличился, то есть проверяем, находится ли шар в состоянии подъема
        //Переключаем шар перед каждым поднятием
        if (lastSlope && fraction > mLastFraction) {
          isNext = !isNext;
        }
        //Записываем, уменьшалась ли lastFraction
        lastSlope = fraction < mLastFraction;
        //Записываем fraction из предыдущего раза
        mLastFraction = fraction;

 Эти два кода используются для расчета времени переключения движения шара, этот анимационный ролик настроен на повторное воспроизведение, и режим повторения установлен на обратный порядок воспроизведения, поэтому один цикл анимации представляет собой процесс, когда шар поднимается и падает. В этом процессе значение fraction сначала равно 0, затем становится 1, а затем снова становится 0. Когда начинается новый цикл анимации? Это происходит в момент, когда шар готов к подниманию, и в этот момент можно переключить движение шара, чтобы实现 эффект анимации, когда шар слева падает, а шар справа поднимается, и когда шар справа падает, шар слева поднимается. 

Так как же поймать этот момент времени? 

В момент подбрасывания шара значение fraction неуклонно увеличивается, а в момент падения значение fraction неуклонно уменьшается. В момент, когда шар готов к подбрасыванию, это момент, когда fraction переходит от неуклонного уменьшения к неуклонному увеличению. В коде регистрируется, было ли значение fraction неуклонно уменьшаться в последний раз, и сравнивается с текущим значением fraction, если оба условия выполняются, то переключается на движение шара. 

    anim.setDuration(200);
    //Настройка interpolатора, контроль скорости изменения анимации
    anim.setInterpolator(new DecelerateInterpolator());
    anim.start();

Настройка продолжительности анимации в 200 миллисекунд, читатель может изменить это значение, чтобы изменить скорость колебания шара.

Настройка анимации interpolатора, так как подбрасывание шара - это процесс постепенного замедления, а падение - это процесс постепенного ускорения, поэтому используется DecelerateInterpolator для реализации эффекта замедления, а при обратном воспроизведении - эффект ускорения. 

Анимация запуска, пользовательский интерфейс для прогресс-бара с эффектом маятника реализован! Поторопитесь запустить и看看效果吧!

Вот и все, что было в этой статье, надеюсь, это поможет вам в изучении. Также希望大家多多支持呐喊教程。

Заявление: содержимое статьи взято из Интернета, авторские права принадлежат соответствующему владельцу. Контент предоставлен пользователями Интернета, загружен самостоятельно, сайт не имеет права собственности, не был обработан вручную, и не несет ответственности за соответствующие юридические вопросы. Если вы обнаружите подозрительное содержание, пожалуйста, отправьте письмо по адресу: notice#oldtoolbag.com (во время отправки письма замените # на @) для сообщения о нарушении и предоставьте соответствующие доказательства. Если факт будет подтвержден, сайт немедленно удаляет подозрительное содержимое.

Основной учебник
Вам может понравиться