MotionLayout 实现顶部栏拉伸效果
MotionLayout 是 google 新推出的 UI 组件,是 ConstraintLayout 2.0
库的一个大更新。它继承自 ConstraintLayout
可以方便的制作复杂动画界面,通过在 xml 中设置起始状态,关键帧,结束状态,快速实现界面动效或动画效果。
本文以代码示例介绍,如何使用 MotionLayout 实现复杂的凸显型顶部栏(Prominent top app bars) 。
实现效果
1. 凸显型顶部栏上下滑动拉伸效果
2. 上下滑动拉伸效果及旋转和变色效果
代码分析
1. 凸显型顶部栏上下滑动实现
从界面布局上分析,如果只是静态布局,外部使用 ConstraitLayout,上半部分凸显型顶部栏直接使用布局填充,下半部分使用 RecyclerView 列表,每个 Item 做独立布局实现即可,这是很好实现的。
但使用常规的方法,实现这个上下滑动效果是比较复杂的。使用 MotionLayout 替换 ConstraitLayout, 再在 app:layoutDescription
属性中指定描述文件,描述文件中增加起始状态、中间帧、结束状态,就可以快速实现这个效果。
状态描述文件保存在 res/xml
目录下,包含 MotionScene
根节点,Transition
节点及若干 ConstraintSet
节点。
Transition
定义了起始状态和结束状态及关键帧。例如下面状态片段中,指定了开始状态 @id/expanded
结束状态 @id/collapsed
,OnSwipe
操作的方向则是 dragUp
,同时以 @id/list
顶部为锚点。KeyFrameSet
中则指定了两个关键帧,进度 60
时 @id/toolbar_image
的 alpha
修改为不透明,而进度到 90
时则修改为全透明,即滑动快到顶部时隐藏背景图片。
<Transition
app:constraintSetEnd="@id/collapsed"
app:constraintSetStart="@id/expanded"
app:interpolator="bounce">
<OnSwipe
app:dragDirection="dragUp"
app:touchAnchorId="@id/list"
app:touchAnchorSide="top" />
<KeyFrameSet>
<KeyAttribute
app:framePosition="60"
app:target="@id/toolbar_image">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="255" />
</KeyAttribute>
<KeyAttribute
app:framePosition="90"
app:target="@id/toolbar_image">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="0" />
</KeyAttribute>
</KeyFrameSet>
</Transition>
ConstraintSet
则和 ConstraitLayout
内容没有太大区别,只是将变动的View 名统一修改为 Constraint
,移除部分不受影响或不变化的属性,将变动的属性填写即可。
例如下面的代码片段中,扩展状态 @+id/expanded
下 @+id/toolbar_image
的高度为 @dimen/notification_toolbar_height
;@+id/mesh_image
的 alpha
调整为 1
。
合并状态 @+id/collapsed
下则刚好相反,@id/toolbar_image
高度变更为 71dp
,@id/mesh_image
高度也变更为 71dp
且透明度调整为 0
。这样设置后,上下滑动时就能在这两个状态中切换。
<ConstraintSet android:id="@+id/expanded">
<Constraint
android:id="@+id/toolbar_image"
android:layout_height="@dimen/notification_toolbar_height"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@+id/mesh_image"
android:layout_height="@dimen/notification_toolbar_height"
android:alpha="1"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ConstraintSet android:id="@+id/collapsed">
<Constraint
android:id="@id/toolbar_image"
android:layout_height="71dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@id/mesh_image"
android:layout_height="71dp"
android:alpha="0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
由于目前 2.0.0alpha2
编辑器支持不太好,可以先按照 ConstraitLayout 实现布局,之后再调整 MotionScene
中的状态信息。
实现步骤:
- 使用 ConstraitLayout 实现凸显型顶部栏布局
- 将 ConstraitLayout 替换为 MotionLayout
- 添加
app:layoutDescription="@xml/collapsing_notification"
描述 - 新建
res/xml/collapsing_notification
状态描述文件 - 填写
Transition
内容,定义起始状态及关键帧状态 - 填写
ConstraintSet
布局内容,定义起始状态布局和结束状态布局
主布局代码 activity_group_notification.xml
如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/collapsing_notification"
app:showPaths="false"
tools:context=".activity.InboxGroupActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/notification_content_background"
android:clipToPadding="false"
android:paddingBottom="8dp"
app:layout_constraintBottom_toTopOf="@+id/bottom_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_image" />
<ImageView
android:id="@+id/toolbar_image"
android:layout_width="0dp"
android:layout_height="@dimen/notification_toolbar_height"
android:adjustViewBounds="true"
android:background="@color/notification_top_end"
android:contentDescription="@null"
android:fitsSystemWindows="true"
android:scaleType="center"
android:src="@drawable/notification_top_background"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/mesh_image"
android:layout_width="0dp"
android:layout_height="@dimen/notification_toolbar_height"
android:adjustViewBounds="true"
android:contentDescription="@null"
android:fitsSystemWindows="true"
android:scaleType="center"
android:src="@drawable/notification_mesh_bg"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="?android:attr/textColorPrimaryInverse"
android:textSize="64sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.48"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/toolbar_image" />
<TextView
android:id="@+id/status_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="8dp"
android:gravity="center"
android:text="@string/notification_unit"
android:textColor="?android:attr/textColorPrimaryInverse"
app:layout_constraintBottom_toBottomOf="@+id/status"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/status"
app:layout_constraintTop_toTopOf="@+id/status"
app:layout_constraintVertical_bias="0.32" />
<TextView
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:gravity="center"
android:text="@string/notification_description"
android:textColor="?android:attr/textColorPrimaryInverse"
android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="@+id/toolbar_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status" />
<ImageView
android:id="@+id/home_button"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginTop="25dp"
android:contentDescription="@null"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp"
android:src="@drawable/home_arrow"
android:tint="?android:attr/textColorPrimaryInverse"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="PrivateResource" />
<ImageView
android:id="@+id/menu_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
android:src="@drawable/ic_more_vert_white_24dp"
android:tint="?android:attr/textColorPrimaryInverse"
app:layout_constraintBottom_toBottomOf="@+id/home_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/home_button"
tools:ignore="PrivateResource" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notification_title"
android:textColor="?android:attr/textColorPrimaryInverse"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="@+id/home_button"
app:layout_constraintStart_toEndOf="@+id/home_button"
app:layout_constraintTop_toTopOf="@+id/home_button" />
<View
android:id="@+id/bottom_bar"
android:layout_width="match_parent"
android:layout_height="64dp"
android:background="#FFFAFAFA"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/clean_text"
android:layout_width="240dp"
android:layout_height="34dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="-1dp"
android:background="@drawable/prompt_button_background"
android:gravity="center"
android:text="@string/notification_clean_btn"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/bottom_bar" />
<View
android:id="@+id/top_bar_line"
android:layout_width="match_parent"
android:layout_height="1px"
android:background="@color/line_color"
app:layout_constraintBottom_toTopOf="@+id/bottom_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.motion.widget.MotionLayout>
状态描述代码 res/xml/collapsing_notification.xml
如下:
<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Transition
app:constraintSetEnd="@id/collapsed"
app:constraintSetStart="@id/expanded"
app:interpolator="bounce">
<OnSwipe
app:dragDirection="dragUp"
app:touchAnchorId="@id/list"
app:touchAnchorSide="top" />
<KeyFrameSet>
<KeyAttribute
app:framePosition="60"
app:target="@id/toolbar_image">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="255" />
</KeyAttribute>
<KeyAttribute
app:framePosition="90"
app:target="@id/toolbar_image">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="0" />
</KeyAttribute>
</KeyFrameSet>
</Transition>
<ConstraintSet android:id="@+id/expanded">
<Constraint
android:id="@+id/toolbar_image"
android:layout_height="@dimen/notification_toolbar_height"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@+id/mesh_image"
android:layout_height="@dimen/notification_toolbar_height"
android:alpha="1"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="1"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.48"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/toolbar_image" />
<Constraint
android:id="@+id/status_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="1"
app:layout_constraintBottom_toBottomOf="@+id/status"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/status"
app:layout_constraintTop_toTopOf="@+id/status"
app:layout_constraintVertical_bias="0.32" />
<Constraint
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="1"
app:layout_constraintBottom_toBottomOf="@+id/toolbar_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status" />
</ConstraintSet>
<ConstraintSet android:id="@+id/collapsed">
<Constraint
android:id="@id/toolbar_image"
android:layout_height="71dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@id/mesh_image"
android:layout_height="71dp"
android:alpha="0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Constraint
android:id="@id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0"
app:layout_constraintBottom_toTopOf="@+id/list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.48"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/toolbar_image" />
<Constraint
android:id="@id/status_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0"
app:layout_constraintBottom_toBottomOf="@+id/status"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/status"
app:layout_constraintTop_toTopOf="@+id/status"
app:layout_constraintVertical_bias="0.32" />
<Constraint
android:id="@id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0"
app:layout_constraintBottom_toBottomOf="@+id/toolbar_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status" />
</ConstraintSet>
</MotionScene>
2. 上下滑动效果及旋转变色效果融合
要实现 视频二 里的效果也很简单,可以拆分为三部分:
- 上下滑动实现效果
- 背景色渐变切换动画
- 背景圆圈图片旋转动画及数字切换
上下滑动实现效果,背景圆圈设定 90 度旋转
这部分内容和上一小节基本一致,只需加入背景圆圈设定 90 度旋转即可:
<Constraint
android:id="@+id/cleaner_circle"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="54dp"
android:alpha="1"
android:contentDescription="@null"
android:rotation="90"
android:src="@drawable/cleaner_circle_bg"
app:layout_constraintBottom_toBottomOf="@+id/light_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/toolbar_image"
app:layout_constraintTop_toTopOf="@+id/home_button" />
背景颜色渐变动画
背景过渡动画由四个 gradient
背景色组成,通过 AnimationDrawable
添加需要的过渡背景帧然后播放动画:
@SuppressWarnings("ConstantConditions")
private void animateToolbarDrawable(int percent) {
final AnimationDrawable animationDrawable = new AnimationDrawable();
List<Integer> rgbColors = new ArrayList<>();
int level = range(percent);
int duration = ANIMATION_DURATION / level;
if (level >= 0) {
animationDrawable.addFrame(getDrawable(R.drawable.status_color_low), duration);
rgbColors.add(ContextCompat.getColor(this, R.color.status_low_end));
}
if (level >= 2) {
animationDrawable.addFrame(getDrawable(R.drawable.status_color_medium), duration);
rgbColors.add(ContextCompat.getColor(this, R.color.status_medium_end));
}
if (level >= 3) {
animationDrawable.addFrame(getDrawable(R.drawable.status_color_high), duration);
rgbColors.add(ContextCompat.getColor(this, R.color.status_high_end));
}
if (level >= 4) {
animationDrawable.addFrame(getDrawable(R.drawable.status_color_critical), duration);
rgbColors.add(ContextCompat.getColor(this, R.color.status_critical_end));
}
mToolbarImage.setImageDrawable(animationDrawable);
animationDrawable.setOneShot(true);
animationDrawable.setEnterFadeDuration(duration - 200);
animationDrawable.setExitFadeDuration(duration - 300);
animationDrawable.start();
final ValueAnimator valueAnimator = ValueAnimator.ofArgb(toIntArray(rgbColors));
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.setDuration(ANIMATION_DURATION);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mToolbarImage.setBackgroundColor((Integer) valueAnimator.getAnimatedValue());
}
});
valueAnimator.start();
}
int[] toIntArray(List<Integer> list) {
int[] ret = new int[list.size()];
int i = 0;
for (Integer e : list) {
ret[i++] = e;
}
return ret;
}
public static int range(int percent) {
if (percent < 40) {
return 1;
} else if (percent < 65) {
return 2;
} else if (percent < 85) {
return 3;
} else if (percent <= 100) {
return 4;
} else {
return 0;
}
}
gradient
背景色 status_color_low.xml
示例:
<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<gradient android:angle="90" android:centerColor="@color/status_low_center" android:endColor="@color/status_low_end" android:startColor="@color/status_low_start" />
</shape>
</item>
</selector>
背景圆圈图片旋转动画及数字切换
背景图片旋转图片使用 RotateAnimation
实现,并设置加速减速产生器,其中的结束角度计算 (360 * Math.ceil(12 / 3.0)
则是按照动画时长 ANIMATION_DURATION
估算的一个值。
private void animateCleanerCircle() {
RotateAnimation animation = new RotateAnimation(0,
(float) (360 * Math.ceil(12 / 3.0)),
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f);
animation.setDuration((long) (ANIMATION_DURATION));
animation.setRepeatCount(0);
animation.setInterpolator(new AccelerateDecelerateInterpolator());
mCleanerCircle.clearAnimation();
mCleanerCircle.startAnimation(animation);
}
数字切换动画也是类似:
private void animateMemoryText(float usedMemory) {
ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, usedMemory);
valueAnimator.setDuration((long) (ANIMATION_DURATION));
valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
float value = (float) valueAnimator.getAnimatedValue();
mMemoryText.setText(String.format(Locale.US, "%.2f", value));
}
});
valueAnimator.start();
}