高仿:来自鸿蒙智行,华为问界的智仓系统,语音效果
1.效果图

2.功能需求
- 双向动画效果 :
- 展开时:文本从左向右扩展,内容淡入
- 收缩时:文本从右向左收缩,内容淡出
2.自适应流式扩展宽度
自适应流式扩展高度
3.卡片流带炫丽的流光效果
边框跑马灯效果:
- 卡片边框有动态光效
- 光效沿边框移动(跑马灯效果)
- 可自定义光效长度、宽度和速度
毛玻璃背景效果:
- 支持设置背景位图
- 可实现模糊背景效果(代码中部分被注释)
3.实现的思路
第一步: 文字可以自动伸缩
第二步: 卡片添加,自动伸缩
第三步: 流光效果
3.1 文字可以自动伸缩
文本增加,宽度增加,文本较少,宽度减少
3.3.1 扩展的动画
scss
/**
* 启动展开动画
* 设计流程:
* 1. 文本动画:短文本→完整文本(400ms)
* 2. 文本完成后触发卡片展开动画
*/
public void startExpandAnimation() {
// 取消进行中的动画
if (mAnimator != null && mAnimator.isRunning()) {
mAnimator.cancel();
}
// 确保位置固定(防止父布局移动)
if (getLeft() != mFixedLeftPosition) {
layout(mFixedLeftPosition, getTop(),
mFixedLeftPosition + getWidth(), getBottom());
}
// 文本长度动画(从当前长度到完整长度)
ValueAnimator textAnimator = ValueAnimator.ofInt(mShortText.length(), mFullText.length());
textAnimator.setDuration(ANIM_DURATION);
textAnimator.addUpdateListener(animation -> {
// 更新当前显示文本长度
mCurrentTextLength = (int) animation.getAnimatedValue();
mHeaderText.setText(mFullText.substring(0, mCurrentTextLength));
// 触发重新布局
requestLayout();
// 文本完全展开后启动卡片展开
if (mCurrentTextLength == mFullText.length()) {
// expandCard();
}
});
textAnimator.start();
}
3.3.2 动态测量
ini
/**
* 测量布局尺寸
* 设计思路:
* 1. 分别测量标题和内容区域
* 2. 计算最大内容宽度
* 3. 根据展开状态确定最终高度
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 测量标题区域
int headerWidthSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
int headerHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
mHeaderText.measure(headerWidthSpec, headerHeightSpec);
mHeaderHeight = mHeaderText.getMeasuredHeight();
mMaxContentWidth = mHeaderText.getMeasuredWidth();
// 测量内容区域(仅展开状态或文本完全展开时)
if (mContentView != null && (mIsExpanded || mCurrentTextLength == mFullText.length())) {
int contentWidthSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
int contentHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
mContentView.measure(contentWidthSpec, contentHeightSpec);
mExpandedHeight = mHeaderHeight + mContentView.getMeasuredHeight();
mMaxContentWidth = Math.max(mMaxContentWidth, mContentView.getMeasuredWidth());
}
// 添加内边距
mMaxContentWidth += getPaddingLeft() + getPaddingRight();
// 确定最终尺寸
int width = resolveSize(mMaxContentWidth, widthMeasureSpec);
int height = mIsExpanded ? mExpandedHeight : mCollapsedHeight;
setMeasuredDimension(width, height);
}
标题区域为什么宽度会自动根本文本宽度,动态变化?
` mHeaderText.measure(headerWidthSpec, headerHeightSpec);
mMaxContentWidth = mHeaderText.getMeasuredWidth();
int width = resolveSize(mMaxContentWidth, widthMeasureSpec); `
resolveSize() 的工作方式:
父容器约束 | 结果宽度 |
---|---|
EXACTLY(精确值) | 使用父容器指定值 |
AT_MOST(最大值) | min(父容器最大值, mMaxContentWidth) |
UNSPECIFIED(无限制) | 使用mMaxContentWidth |
3.3.3 动态布局
为什么要重写整个方法onlayout,作用是干嘛的 1).重写 onLayout()
方法在自定义 ViewGroup(如 AnimationCardView)中是关键步骤,它决定了子视图在父容器中的具体位置和尺寸。这个方法的实现对于实现卡片的特殊布局行为至关重要
2). 要自己算位置,他是继承 ViewGroup, 又不是LineaLayout,
-
自定义需求:
- 标题始终在顶部
- 内容区域在标题下方
- 需要处理内边距
为什么不能使用默认布局?
FrameLayout 默认行为的问题
- 堆叠问题:所有子视图重叠在 (0,0) 位置
- 无垂直排列:无法实现标题+内容的垂直布局
- 状态不感知:无法根据展开状态显示/隐藏内容
- 位置控制:无法实现左侧位置固定

3.2 卡片添加,自动伸缩
scss
/**
* 执行卡片展开动画
* 动画效果:
* 1. 高度变化:当前高度→展开高度(200ms)
* 2. 内容区域渐显效果
*/
private void expandCard() {
mIsExpanded = true;
if (mContentView != null) {
mContentView.setVisibility(View.VISIBLE);// Visible
mContentView.setAlpha(0f); // 初始完全透明
}
// 高度变化动画
ValueAnimator heightAnim = ValueAnimator.ofInt(getHeight(), mExpandedHeight);
heightAnim.setDuration(ANIM_DURATION / 2);
heightAnim.addUpdateListener(animation -> {
// 更新布局高度
ViewGroup.LayoutParams params = getLayoutParams();
params.height = (int) animation.getAnimatedValue();
setLayoutParams(params);
// 内容区域透明度渐变
if (mContentView != null) {
mContentView.setAlpha(animation.getAnimatedFraction());
}
// 强制保持左侧位置
if (getLeft() != mFixedLeftPosition) {
layout(mFixedLeftPosition, getTop(),
mFixedLeftPosition + getWidth(), getBottom());
}
});
heightAnim.start();
}
重要的代码过程:
ViewGroup.LayoutParams params = getLayoutParams(); params.height = (int) animation.getAnimatedValue(); setLayoutParams(params);
layout(mFixedLeftPosition, getTop(), mFixedLeftPosition + getWidth(), getBottom());
设置属性,是否重新测量和布局?比如设置透明度?
动画上面的过程: layout,不重新测量也没关系,也可以直接调用requestLayout 总结:
1). 卡片从矮变高的过程中,添加了动画
2).动画变化的时候,触发重新绘制requestLayout,刷新了界面和UI效果
3.3 流光效果
3.3.1 流光的动画
ini
private void startLightAnimation() {
if (mLightAnimator != null && mLightAnimator.isRunning()) {
return;
}
mLightAnimator = ValueAnimator.ofFloat(0, 1);
mLightAnimator.setDuration(DEFAULT_LIGHT_DURATION);
mLightAnimator.setRepeatCount(ValueAnimator.INFINITE);
mLightAnimator.addUpdateListener(animation -> {
mCurrentProgress = (float) animation.getAnimatedValue();
updateLightPath();
invalidate();
});
mLightAnimator.start();
}
3.3.2 绘制流光的路径
ini
private void updateLightPath() {
mLightPath.reset();
// 计算流光起点和终点
float start = mCurrentProgress * mPathLength;
float end = (start + 0.2f * mPathLength) % mPathLength;
if (start < end) {
mPathMeasure.getSegment(start, end, mLightPath, true);
} else {
mPathMeasure.getSegment(start, mPathLength, mLightPath, true);
mPathMeasure.getSegment(0, end, mLightPath, true);
}
// 添加箭头效果
addArrowToLight(end);
}
private void addArrowToLight(float endPos) {
float[] pos = new float[2];
float[] tan = new float[2];
mPathMeasure.getPosTan(endPos, pos, tan);
Path arrowPath = new Path();
arrowPath.moveTo(pos[0], pos[1]);
arrowPath.lineTo(pos[0] - tan[1] * 12 - tan[0] * 8, pos[1] + tan[0] * 12 - tan[1] * 8);
arrowPath.moveTo(pos[0], pos[1]);
arrowPath.lineTo(pos[0] + tan[1] * 12 - tan[0] * 8, pos[1] - tan[0] * 12 - tan[1] * 8);
mLightPath.addPath(arrowPath);
}
3.3.3 进行重绘
scss
protected void dispatchDraw(Canvas canvas) {
// 先绘制背景
super.dispatchDraw(canvas);
// 绘制流光效果
if (mIsLightEnabled && mLightPath != null && !mLightPath.isEmpty()) {
canvas.drawPath(mLightPath, mLightPaint);
}
}
3.3.4 大小变化要进行重新绘制
scss
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 更新卡片矩形区域
mCardRect.set(0, 0, w, h);
// 创建卡片边框路径(圆角矩形)
mBorderPath.reset();
mBorderPath.addRoundRect(mCardRect, mCornerRadius, mCornerRadius, Path.Direction.CW);
// 初始化路径测量
mPathMeasure = new PathMeasure(mBorderPath, true);
mPathLength = mPathMeasure.getLength();
// 启动流光动画
if (mIsLightEnabled) {
startLightAnimation();
}
}
onSizeChanged(),什么时候需要重写这个方法? 以下情况通常需要重写 onSizeChanged()
方法:
场景 | 说明 | 在 AnimationCardViewPlus 中的应用 |
---|---|---|
尺寸依赖的初始化 | 当需要根据视图尺寸创建或初始化资源时 | 初始化流光效果的路径和测量 |
尺寸变化时的重计算 | 当视图尺寸改变时需要更新内部状态 | 更新卡片矩形区域和路径 |
性能敏感的操作 | 避免在 onDraw() 中重复计算 |
路径测量等操作只在此执行 |
动画启动/重置 | 尺寸变化时重新启动动画 | 启动流光动画 |
子视图位置计算 | 需要根据新尺寸调整子视图位置 | (虽未直接使用,但可用于布局优化) |
为什么必须在 onSizeChanged()
中处理?
-
首次尺寸分配:
- 视图首次获得尺寸时调用
- 确保流光效果在视图可见前初始化完成
-
响应尺寸变化:
- 屏幕旋转时
- 布局参数改变时(如宽度从
wrap_content
变为固定值) - 父容器尺寸变化导致连锁反应
-
避免冗余计算:
- 只在尺寸实际变化时执行
- 通过
oldw
和oldh
可判断是否真变化
scssif (w != oldw || h != oldh) { // 只有尺寸变化时才执行 }
-
正确的时间点:
- 在
onMeasure()
之后 - 在
onLayout()
之前 - 确保布局前路径已准备好
- 在
4.整体的架构图


5.总结
5.1 整体的思路:
1.文本从左到右变大的动画,进行不停的测量和布局,动态测量出文本的宽度和高度,然后布局
2.当文本到达最大宽度的时候,卡片进行从窄到宽的动画,不停的触发测量和布局
- 当整体变化的时候,进行流光的动画效果,然后进行计算路径,最后绘制 最终形成了可动态变化的效果