Отличный Android UX: Как сделать кнопку свайпа (swipe button)

Суть дизайна не в том, чтобы сделать что-то красивое, а в том, чтобы сделать что-то отличное. Вы можете делать хорошие приложения различными способами, но один из моих любимых — сделать его максимально простым и интуитивно понятным, но при этом оригинальным. Важно, чтобы ваш пользовательский интерфейс (UI) реагировал на пользователя в соответствии с важностью определённого действия. Иногда это действие слишком важно, чтобы запускать его простым кликом, например, разблокирование смартфона. Нажатие на кнопку и получение диалога для подтверждения это нормально. Это работает. Но все так делают! Почему бы не сделать иначе?

Что ж, сегодня мы это сделаем.

В этом уроке я покажу, как сделать кнопку свайпа. Она позволяет пользователю совершать важные действия, не обязательно подтверждая свои намерения, потому что трудно случайно нажать на кнопку (но, пожалуйста, помните: это не невозможно, так что будьте осторожны!). Таким образом, у вас есть гораздо более интуитивный. приятный и простой UX.

Круто? Итак, начнём!

Урок основан на этом репозитории. Если вы хотите использовать готовую кнопку, вы можете добавить его в свой проект через Gradle.

Часть 1 — Давайте создадим наш XML

Наша кнопка имеет круглую движущуюся часть и округлый фон. Итак, давайте сделаем эти части:

Круг, shape_button.xml:

<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
  <shape android:shape="rectangle">
    <corners android:radius="50dp" />
    <solid android:color="@android:color/white" />
  </shape>
</item>
</selector>

И фон: shape_rounded.xml:

<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="false" android:state_selected="false">
  <shape android:shape="rectangle">
    <corners android:radius="150dp" />
    <solid android:color="#4D6070" />
  </shape>
</item>
</selector>

Радиус может быть любым, поэтому границы полукруглые. Вы можете использовать другие цвета, если захотите.

Итак, теперь у нас есть xml. Давайте создадим нас пользовательский View.

Часть 2 — Делаем наш собственный View

Мы собираемся наследовать RelativeLayout, как показано ниже:

public class SwipeButton extends RelativeLayout {
    private ImageView slidingButton;
    private float initialX;
    private boolean active;
    private int initialButtonWidth;
    private TextView centerText;

    private Drawable disabledDrawable;
    private Drawable enabledDrawable;
    public SwipeButton(Context context) {
        super(context);

        init(context, null, -1, -1);
    }

    public SwipeButton(Context context, AttributeSet attrs) {
        super(context, attrs);

        init(context, attrs, -1, -1);
    }

    public SwipeButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        init(context, attrs, defStyleAttr, -1);
    }

    @TargetApi(21)
    public SwipeButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context, attrs, defStyleAttr, defStyleRes);
    }
    private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    //Не беспокойтесь об этом методе сейчас... Я расскажу о нём чуть позже.
    }
}

Вы, должно быть, думаете… «Это кнопка, почему мы наследуем RelativeLayout?»

Поскольку это составная кнопка, нам нужно добавить несколько представлений (views), чтобы иметь возможность сконструировать поведением, которое нам нужно, поэтому нам нужен ViewGroup. RelativeLayout — хороший вариант, потому что многие представления будут заходить один на другой в этом компоненте.

У нас есть много внутренних представлений. Итак, давайте разберём эту реализацию поэтапно.

Для вас также важно понятно переменные, объявленные в классе:

  • slidingButton — это движущаяся компонента. Она содержит иконку.
  • initialX — это позиция движущейся части, когда пользователь начнёт её перемещать.
  • active — это переменная, которая говорит, активна кнопка или нет.
  • initialButtonWidth — это начальная ширина движущейся части. Нам нужно сохранить её, чтобы мы могли вернуться в исходное положение.
  • centerText — это текст в центре кнопки.
  • disabledDrawable / enadledDrawable — иконки, которые движущаяся часть будет использовать, когда кнопка активна или неактивна. Используйте те иконки. которые нравятся вам.

Фон

Сначала добавим фон:

private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    RelativeLayout background = new RelativeLayout(context);

    LayoutParams layoutParamsView = new LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.WRAP_CONTENT);

    layoutParamsView.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE);

    background.setBackground(ContextCompat.getDrawable(context, R.drawable.shape_rounded));

    addView(background, layoutParamsView);
}

Этот View отвечает за округленный фон кнопки.

Текст

Теперь мы добавим в конец метода init() информативный текст для кнопки.

final TextView centerText = new TextView(context);
this.centerText = centerText;

LayoutParams layoutParams = new LayoutParams(
        ViewGroup.LayoutParams.WRAP_CONTENT,
        ViewGroup.LayoutParams.WRAP_CONTENT);

layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE);
centerText.setText("SWIPE"); //добавьте текст, который вам нужен
centerText.setTextColor(Color.WHITE);
centerText.setPadding(35, 35, 35, 35);
background.addView(centerText, layoutParams);

Это текст в центре компоненты. Я просто захардкодил текст для простоты, но вы можете использовать строковые ресурсы для интернационализации. Вы можете добавить отступы как вам нравится. Именно так вы контролируете размер фона вашего изображения.

Добавление движущейся иконки

final ImageView swipeButton = new ImageView(context);
this.slidingButton = swipeButton;

disabledDrawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_lock_open_black_24dp);
enabledDrawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_lock_outline_black_24dp);

slidingButton.setImageDrawable(disabledDrawable);
slidingButton.setPadding(40, 40, 40, 40);

LayoutParams layoutParamsButton = new LayoutParams(
        ViewGroup.LayoutParams.WRAP_CONTENT,
        ViewGroup.LayoutParams.WRAP_CONTENT);

layoutParamsButton.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
layoutParamsButton.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE);
swipeButton.setBackground(ContextCompat.getDrawable(context, R.drawable.shape_button));
swipeButton.setImageDrawable(disabledDrawable);
addView(swipeButton, layoutParamsButton);

Это движущаяся часть компоненты. Мы должны установить иконки для неактивного и активного состояний. Настройка отступов позволяет установить размер движущейся части.

Добавление слушателя касания

setOnTouchListener(getButtonTouchListener());

Добавьте эту строку в конец метода init(). Мы поговорим о ней в следующем разделе. В результате init() должен выглядеть следующим образом:

private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    RelativeLayout background = this;

    LayoutParams layoutParamsView = new LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.WRAP_CONTENT);

    layoutParamsView.addRule(RelativeLayout.CENTER_IN_PARENT,   RelativeLayout.TRUE);

    background.setBackground(ContextCompat.getDrawable(context, R.drawable.shape_rounded));
    addView(background, layoutParamsView);
    final TextView centerText = new TextView(context);
    this.centerText = centerText;
    centerText.setGravity(Gravity.CENTER);

    LayoutParams layoutParams = new LayoutParams(
        ViewGroup.LayoutParams.WRAP_CONTENT,
        ViewGroup.LayoutParams.WRAP_CONTENT);

    layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE);
    centerText.setText("SWIPE"); //добавьте текст, который вам нужен
    centerText.setTextColor(Color.WHITE);
    background.addView(centerText, layoutParams);
    final ImageView swipeButton = new ImageView(context);
    this.slidingButton = swipeButton;

    LayoutParams layoutParamsButton = new LayoutParams(
        ViewGroup.LayoutParams.WRAP_CONTENT,
        ViewGroup.LayoutParams.WRAP_CONTENT);

    layoutParamsButton.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
    layoutParamsButton.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE);
    swipeButton.setBackground(ContextCompat.getDrawable(context, R.drawable.shape_button));
    swipeButton.setImageDrawable(disabledDrawable);
    addView(swipeButton, layoutParamsButton);
    setOnTouchListener(getButtonTouchListener());
}

Уже сейчас вы можете добавить кнопку в своё приложение и увидеть результат. Например, кнопка со следующей разметкой:

<your.package.here.SwipeButton
    android:id="@+id/swipe_btn"
    android:layout_width="match_parent"
    android:layout_height="100dp"
    android:layout_marginStart="20dp"
    android:layout_marginEnd="20dp"
    />

Будет выглядеть так:

Пока всё выглядит хорошо. Но мы не реализовали поведение. В следующем разделе вы найдёте логику для своей кнопки!

Часть 3 — Реализация логики кнопки

Давайте реализуем метод getButtonTouchLisneter().

private OnTouchListener getButtonTouchListener() {
    return new OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                return true;
            case MotionEvent.ACTION_MOVE: 
                //Логика перемещения
                return true;
            case MotionEvent.ACTION_UP:
                //Логика раскрытия
                return true;
            }

        return false;
        }
    };
}

Теперь у нас есть слушатель. Давайте добавим логику в ACTION_MOVE.

Логика перемещения

if (initialX == 0) {
    initialX = slidingButton.getX();
}
if (event.getX() > initialX + slidingButton.getWidth() / 2 &&
        event.getX() + slidingButton.getWidth() / 2 < getWidth()) {
    slidingButton.setX(event.getX() - slidingButton.getWidth() / 2);
    centerText.setAlpha(1 - 1.3f * (slidingButton.getX() + slidingButton.getWidth()) / getWidth());
}

if  (event.getX() + slidingButton.getWidth() / 2 > getWidth() &&
        slidingButton.getX() + slidingButton.getWidth() / 2 < getWidth()) {
    slidingButton.setX(getWidth() - slidingButton.getWidth());
}

if  (event.getX() < slidingButton.getWidth() / 2 &&
        slidingButton.getX() > 0) {
    slidingButton.setX(0);
}

Давайте разберём условия.

Первое заключается в том. чтобы убедиться, что мы знаем начальную позицию кнопки, если она не равна нулю.

Второе условие отвечает за касание кнопки пальцем. event.getX() возвращает позицию текущего касания. В этой части кода мы устанавливаем центр кнопки в положение касания и уменьшаем альфа-канала текста по мере свайпа.

Третье и четвёртое условия несут ответственность за то, чтобы установить положение подвижной части на границы компонента, когда пользователь переместится за пределы.

Логика раскрытия

В раскрытии нам нужны три метода:

case MotionEvent.ACTION_UP:
    if (active) {
        collapseButton();
    } else {
        initialButtonWidth = slidingButton.getWidth();

        if (slidingButton.getX() + slidingButton.getWidth() > getWidth() * 0.85) {
            expandButton();
        } else {
            moveButtonBack();
        }
    }

    return true;
}

Если кнопка отпущена и она активна, нам нужно её свернуть. Если кнопка сдвинута очень близко к границе компонента, тогда нужно её развернуть. Если кнопка отпускается далеко от правого края, она должна проделать путь обратно.

Примечание: Если вы хотите подробнее узнать, как создавать анимацию, то вы можете пройти по ссылке.

Вот эти анимации:

Анимация расширения

private void expandButton() {
    final ValueAnimator positionAnimator =
            ValueAnimator.ofFloat(slidingButton.getX(), 0);
    positionAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            float x = (Float) positionAnimator.getAnimatedValue();
            slidingButton.setX(x);
        }
    });


    final ValueAnimator widthAnimator = ValueAnimator.ofInt(
            slidingButton.getWidth(),
            getWidth());

    widthAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            ViewGroup.LayoutParams params = slidingButton.getLayoutParams();
            params.width = (Integer) widthAnimator.getAnimatedValue();
            slidingButton.setLayoutParams(params);
        }
    });


    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);

            active = true;
            slidingButton.setImageDrawable(enabledDrawable);            
        }
    });

    animatorSet.playTogether(positionAnimator, widthAnimator);
    animatorSet.start();
}

Анимация кажется сложной, правда? Не волнуйся, у тебя всё получится! Позволь мне объяснить:

Во-первых, slidingButton будет анимировать левый край вашего компонента. Это positionAnimator.

Во-вторых, кнопка должна расширяться и занимать всё пространство компонента. Это widthAnimator.

В-третьих, мы создаём AnimatorSet, чтобы можно было вместе воспроизводить анимацию. В конце анимации мы устанавливаем состояние кнопки как активное и меняем изображение.

Анимация сворачивания

private void collapseButton() {
    final ValueAnimator widthAnimator = ValueAnimator.ofInt(
            slidingButton.getWidth(),
            initialButtonWidth);

    widthAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            ViewGroup.LayoutParams params =  slidingButton.getLayoutParams();
            params.width = (Integer) widthAnimator.getAnimatedValue();
            slidingButton.setLayoutParams(params);
        }
    });

    widthAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);
            active = false;
            slidingButton.setImageDrawable(disabledDrawable);
        }
    });

    ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(
            centerText, "alpha", 1);

    AnimatorSet animatorSet = new AnimatorSet();

    animatorSet.playTogether(objectAnimator, widthAnimator);
    animatorSet.start();
}

Эта анимация похожа на расширение кнопки, но на этот раз мы увеличиваем альфа-канал текста, поэтому он может стать видимым.

Возвращение кнопки назад

private void moveButtonBack() {
    final ValueAnimator positionAnimator =
            ValueAnimator.ofFloat(slidingButton.getX(), 0);
    positionAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
    positionAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            float x = (Float) positionAnimator.getAnimatedValue();
            slidingButton.setX(x);
        }
    });

    ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(
            centerText, "alpha", 1);

    positionAnimator.setDuration(200);

    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.playTogether(objectAnimator, positionAnimator);
    animatorSet.start();
}

Эта анимация перемещает кнопку назад к левому краю и восстанавливает альфа-канал текста.

Часть 4 — Результат

Если вы всё сделали правильно, то получите следующий результат (используйте тёмный фон на вашей активности):

На этом всё, вы сделали это!

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *