Программирование
Как добавить анимацию переворачивания карточки в Android-приложение
Анимация может помочь нам улучшить взаимодействие с пользователем.
Недавно мы в HealthifyMe обновили дашборд, чтобы сделать его более привлекательным для пользователей. И мы попробовали действительно классные анимации. Одна из них — анимация переворачивающейся карточки. Если пользователь отслеживает потребление воды, сон или что-то еще, и достигает своей цели, мы переворачиваем карточку с прогресса на вознаграждение.
В этой статье мы покажем, как это реализовать.
Переворачивание карточки
Шаг 1. Создаем дизайн лицевой и оборотной стороны карточки.
Card_gratification.xml — дизайн задней части.
<?xml version="1.0" encoding="utf-8"?> | |
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:app="http://schemas.android.com/apk/res-auto" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent"> | |
<androidx.constraintlayout.widget.Guideline | |
android:id="@+id/guide_start" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
android:orientation="vertical" | |
app:layout_constraintGuide_percent=".30" /> | |
<androidx.constraintlayout.widget.Guideline | |
android:id="@+id/guide_end" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
android:orientation="vertical" | |
app:layout_constraintGuide_percent=".70" /> | |
<ImageView | |
android:id="@+id/btn_share_achievement" | |
android:layout_width="@dimen/button_height" | |
android:layout_height="@dimen/button_height" | |
android:background="?attr/selectableItemBackgroundBorderless" | |
android:padding="@dimen/card_padding" | |
android:src="@drawable/ic_share_dashboard_card" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintTop_toTopOf="parent" /> | |
<com.airbnb.lottie.LottieAnimationView | |
android:id="@+id/img_gratification" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintDimensionRatio="1:1" | |
app:layout_constraintEnd_toEndOf="@id/guide_end" | |
app:layout_constraintStart_toStartOf="@id/guide_start" | |
app:layout_constraintTop_toTopOf="parent" | |
app:layout_constraintVertical_bias=".25" | |
app:lottie_autoPlay="true" | |
app:lottie_loop="false" /> | |
<TextView | |
android:id="@+id/txt_gratification" | |
style="@style/SansMediumXMediumTextViewStyle" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/goal_achieved" | |
android:textColor="@color/text_color_black" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@id/img_gratification" | |
app:layout_constraintVertical_bias=".23" /> | |
<TextView | |
android:id="@+id/btn_got_it" | |
style="@style/SansSmallMediumTextViewStyle" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:layout_marginTop="4dp" | |
android:background="?android:attr/selectableItemBackgroundBorderless" | |
android:text="@string/got_it" | |
android:textAllCaps="true" | |
android:textColor="@color/water_track_accent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@id/txt_gratification" /> | |
</androidx.constraintlayout.widget.ConstraintLayout> |
Здесь вместо определения родительскому View фиксированной ширины и высоты я предоставляю match_parent. Так можно поддерживать устройства разных размеров. Чтобы понять это, вы можете прочитать мою предыдущую статью «Выбор наилучшего макета при разработке приложения».
Перейдем к дизайну лицевой части, который представляет собой прогресс потребления воды.
Card_water_tracker.xml
<androidx.constraintlayout.widget.ConstraintLayout | |
android:id="@+id/water_progress_view" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
android:background="@drawable/shadow_background" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintDimensionRatio="1:1" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent"> | |
<androidx.constraintlayout.widget.Guideline | |
android:id="@+id/guide_start" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
android:orientation="vertical" | |
app:layout_constraintGuide_percent=".30" /> | |
<androidx.constraintlayout.widget.Guideline | |
android:id="@+id/guide_end" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
android:orientation="vertical" | |
app:layout_constraintGuide_percent=".70" /> | |
<com.github.lzyzsd.circleprogress.DonutProgress | |
android:id="@+id/dp_water_progress" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
android:rotation="-90" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintDimensionRatio="1:1" | |
app:layout_constraintEnd_toEndOf="@id/guide_end" | |
app:layout_constraintStart_toStartOf="@id/guide_start" | |
app:layout_constraintTop_toTopOf="parent" | |
app:layout_constraintVertical_bias=".25" | |
custom:donut_finished_color="@color/new_water_track_button_blue" | |
custom:donut_finished_stroke_width="@dimen/small_padding" | |
custom:donut_progress="0" | |
custom:donut_text_color="@color/transparent" | |
custom:donut_unfinished_color="@color/activity_background_grey" | |
custom:donut_unfinished_stroke_width="@dimen/small_padding" /> | |
<ImageView | |
android:id="@+id/img_glass" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:padding="@dimen/small_padding" | |
android:src="@drawable/ic_water_hydration_glass" | |
app:layout_constraintBottom_toBottomOf="@id/dp_water_progress" | |
app:layout_constraintEnd_toEndOf="@id/dp_water_progress" | |
app:layout_constraintStart_toStartOf="@id/dp_water_progress" | |
app:layout_constraintTop_toTopOf="@id/dp_water_progress" | |
app:tint="@color/water_track_accent" /> | |
<ImageView | |
android:id="@+id/btn_add_glass" | |
android:layout_width="@dimen/button_height" | |
android:layout_height="@dimen/button_height" | |
android:background="?attr/selectableItemBackgroundBorderless" | |
android:paddingStart="@dimen/card_padding" | |
android:paddingTop="@dimen/card_padding_more" | |
android:paddingEnd="@dimen/content_gutter" | |
android:paddingBottom="@dimen/card_padding_more" | |
android:scaleType="fitXY" | |
android:src="@drawable/ic_water_plus" | |
app:layout_constraintBottom_toBottomOf="@id/img_glass" | |
app:layout_constraintDimensionRatio="1:1" | |
app:layout_constraintStart_toEndOf="@id/dp_water_progress" | |
app:layout_constraintTop_toTopOf="@id/img_glass" /> | |
<ImageView | |
android:id="@+id/btn_remove_glass" | |
android:layout_width="@dimen/button_height" | |
android:layout_height="@dimen/button_height" | |
android:background="?attr/selectableItemBackgroundBorderless" | |
android:paddingStart="@dimen/content_gutter" | |
android:paddingTop="@dimen/card_padding_more" | |
android:paddingEnd="@dimen/card_padding" | |
android:paddingBottom="@dimen/card_padding_more" | |
android:scaleType="fitXY" | |
android:src="@drawable/ic_water_minus" | |
app:layout_constraintBottom_toBottomOf="@id/img_glass" | |
app:layout_constraintDimensionRatio="1:1" | |
app:layout_constraintEnd_toStartOf="@id/dp_water_progress" | |
app:layout_constraintTop_toTopOf="@id/img_glass" /> | |
<TextView | |
android:id="@+id/txt_water_goal" | |
style="@style/SansMediumXMediumTextViewStyle" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:textColor="@color/text_color_black" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@id/dp_water_progress" | |
app:layout_constraintVertical_bias=".23" | |
tools:text="5 of 10" /> | |
<TextView | |
android:id="@+id/txt_water" | |
style="@style/SansRegularSmallTextViewStyle" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:layout_marginTop="@dimen/small_padding" | |
android:textColor="@color/disabled_text_color" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toBottomOf="@id/txt_water_goal" | |
tools:text="5 of 10" /> | |
</androidx.constraintlayout.widget.ConstraintLayout> |
Шаг 2. Включаем оба макета в один xml.
<?xml version="1.0" encoding="utf-8"?> | |
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:app="http://schemas.android.com/apk/res-auto" | |
xmlns:custom="http://schemas.android.com/apk/res-auto" | |
xmlns:tools="http://schemas.android.com/tools" | |
android:id="@+id/water_container" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent"> | |
<FrameLayout | |
android:id="@+id/view_gratification" | |
android:layout_width="0dp" | |
android:layout_height="0dp" | |
android:visibility="gone" | |
android:background="@drawable/shadow_background" | |
app:layout_constraintBottom_toBottomOf="parent" | |
app:layout_constraintDimensionRatio="1:1" | |
app:layout_constraintEnd_toEndOf="parent" | |
app:layout_constraintStart_toStartOf="parent" | |
app:layout_constraintTop_toTopOf="parent"> | |
<include | |
layout="@layout/card_gratification" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" /> | |
</FrameLayout> | |
<!-- Add card_water_tracker.xml here--> | |
</androidx.constraintlayout.widget.ConstraintLayout> |
Шаг 3. Давайте создадим drawable анимацию. Когда мы переворачиваем карточку, один макет вращается по часовой стрелке и уходит назад, а второй макет, для оборотной стороны, вращается против часовой стрелки и выходит на первый план, так как обе вещи происходят параллельно.
Посмотрим на анимацию повнимательнее.
Flip_out.xml — эта анимация предназначена для карточки, которая находится спереди и которую нужно переместить назад. Поворачиваем ее по оси Y от 0 до 180 градусов. Помните, что когда мы отправляем ее назад, другой макет мы перемещаем на передний план, поэтому, если полная продолжительность анимации составляет 1500, то на половине времени, равном 750, нужно изменить прозрачность alpha с 1 на 0.
<?xml version="1.0" encoding="utf-8"?> | |
<set xmlns:android="http://schemas.android.com/apk/res/android"> | |
<objectAnimator | |
android:duration="1500" | |
android:propertyName="rotationY" | |
android:valueFrom="0" | |
android:valueTo="180" /> | |
<objectAnimator | |
android:duration="0" | |
android:propertyName="alpha" | |
android:startOffset="750" | |
android:valueFrom="1.0" | |
android:valueTo="0.0" /> | |
</set> |
Flip_in.xml: эта анимация для стороны, которая выходит на первый план. Поворачиваем ее в обратном направлении по оси Y.
<?xml version="1.0" encoding="utf-8"?> | |
<set xmlns:android="http://schemas.android.com/apk/res/android"> | |
<objectAnimator | |
android:duration="0" | |
android:propertyName="alpha" | |
android:valueFrom="1.0" | |
android:valueTo="0.0" /> | |
<objectAnimator | |
android:duration="1500" | |
android:propertyName="rotationY" | |
android:repeatMode="reverse" | |
android:valueFrom="-180" | |
android:valueTo="0" /> | |
<objectAnimator | |
android:duration="0" | |
android:propertyName="alpha" | |
android:startOffset="750" | |
android:valueFrom="0.0" | |
android:valueTo="1.0" /> | |
</set> |
Шаг 4: Применяем анимацию к View. Показываем один экран перед поворотом и скрываем его после окончания анимации.
- visibleView — View, который вы хотите вывести на передний план;
- inVisibleView — View, который вы хотите перевернуть.
fun flipCard(context: Context, visibleView: View, inVisibleView: View) { | |
try { | |
visibleView.visible() | |
val flipOutAnimatorSet = | |
AnimatorInflater.loadAnimator( | |
context, | |
R.animator.flip_out | |
) as AnimatorSet | |
flipOutAnimatorSet.setTarget(inVisibleView) | |
val flipInAnimationSet = | |
AnimatorInflater.loadAnimator( | |
context, | |
R.animator.flip_in | |
) as AnimatorSet | |
flipInAnimationSet.setTarget(visibleView) | |
flipOutAnimatorSet.start() | |
flipInAnimationSet.start() | |
flipInAnimatorSet.doOnEnd { | |
inVisibleView.gone() | |
} | |
} catch (e: Exception) { | |
logHandledException(e) | |
} | |
} |
Но переворот теперь выходит за пределы окна. Чтобы избежать этого, нам нужно добавить расстояние до камеры к обоим представлениям.
Шаг 5: Добавляем расстояние до камеры view.CameraDistance
Устанавливает расстояние по оси Z от камеры до этого View. Расстояние до камеры влияет на трехмерные преобразования, например поворот вокруг осей X и Y. Если свойства RotationX или RotationY изменены и это представление большое (больше половины размера экрана), рекомендуется всегда использовать расстояние до камеры, превышающее высоту (при вращении по оси X) или ширину (при вращении по оси Y) этого вида.
Расстояние до камеры от плоскости View может влиять на перспективное искажение, когда он вращается вокруг оси X или Y. Например, большое расстояние приведет к большому углу обзора, и не будет большого перспективного искажения вида при его вращении. Небольшое расстояние может вызвать гораздо большее перспективное искажение при повороте, а также может привести к некоторым артефактам рисования, если повернутый вид оказывается частично позади камеры (вот почему рекомендуется использовать расстояние, по крайней мере, равное размеру View, если вид предполагается вращать).
val cameraDist = 8000 * scale visibleView.cameraDistance = cameraDist inVisibleView.cameraDistance = cameraDist
Вот весь код:
fun flipCard(context: Context, visibleView: View, inVisibleView: View) { | |
try { | |
visibleView.visible() | |
val scale = context.resources.displayMetrics.density | |
val cameraDist = 8000 * scale | |
visibleView.cameraDistance = cameraDist | |
inVisibleView.cameraDistance = cameraDist | |
val flipOutAnimatorSet = | |
AnimatorInflater.loadAnimator( | |
context, | |
R.animator.flip_out | |
) as AnimatorSet | |
flipOutAnimatorSet.setTarget(inVisibleView) | |
val flipInAnimatorSet = | |
AnimatorInflater.loadAnimator( | |
context, | |
R.animator.flip_in | |
) as AnimatorSet | |
flipInAnimatorSet.setTarget(visibleView) | |
flipOutAnimatorSet.start() | |
flipInAnimatorSet.start() | |
flipInAnimatorSet.doOnEnd { | |
inVisibleView.gone() | |
} | |
} catch (e: Exception) { | |
logHandledException(e) | |
} | |
} |
Заключение
Анимация может помочь нам улучшить взаимодействие с пользователем, но слишком большое количество анимаций может отвлекать пользователей от выполнения нужных задач. Поэтому при разработке приложения всегда понимайте, кто ваша аудитория, и как лучше всего сказать им, что нужно сделать то или иное действие.
-
Программирование3 недели назад
Конец программирования в том виде, в котором мы его знаем
-
Видео и подкасты для разработчиков7 дней назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Магазины приложений3 недели назад
Магазин игр Aptoide запустился на iOS в Европе
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.8