4. Android 用户狂赞的UI特效!揭秘折叠卡片+流光动画的终极实现方案

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

1.效果图

2.功能需求

  1. 双向动画效果
    • 展开时:文本从左向右扩展,内容淡入
    • 收缩时:文本从右向左收缩,内容淡出

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 默认行为的问题
  1. 堆叠问题:所有子视图重叠在 (0,0) 位置
  2. 无垂直排列:无法实现标题+内容的垂直布局
  3. 状态不感知:无法根据展开状态显示/隐藏内容
  4. 位置控制:无法实现左侧位置固定

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() 中处理?

  1. 首次尺寸分配

    • 视图首次获得尺寸时调用
    • 确保流光效果在视图可见前初始化完成
  2. 响应尺寸变化

    • 屏幕旋转时
    • 布局参数改变时(如宽度从 wrap_content 变为固定值)
    • 父容器尺寸变化导致连锁反应
  3. 避免冗余计算

    • 只在尺寸实际变化时执行
    • 通过 oldwoldh 可判断是否真变化
    scss 复制代码
    if (w != oldw || h != oldh) {
        // 只有尺寸变化时才执行
    }
  4. 正确的时间点

    • onMeasure() 之后
    • onLayout() 之前
    • 确保布局前路径已准备好

4.整体的架构图

5.总结

5.1 整体的思路:

1.文本从左到右变大的动画,进行不停的测量和布局,动态测量出文本的宽度和高度,然后布局

2.当文本到达最大宽度的时候,卡片进行从窄到宽的动画,不停的触发测量和布局

  1. 当整体变化的时候,进行流光的动画效果,然后进行计算路径,最后绘制 最终形成了可动态变化的效果

5.2 升级: 可以用LinearyLayout替代ViewGroup

6.源码

项目的地址:github.com/pengcaihua1...

相关推荐
程序员是干活的10 分钟前
Java EE前端技术编程脚本语言JavaScript
java·大数据·前端·数据库·人工智能
南囝coding32 分钟前
Coze 开源了!所有人都可以免费使用了
前端·后端·产品
CDwenhuohuo34 分钟前
滚动提示组件
java·前端·javascript
说码解字40 分钟前
Kotlin 内联函数
前端
PineappleCoder41 分钟前
性能优化与状态管理:React的“加速器”与“指挥家”
前端·react.js
_一两风43 分钟前
深入理解React中的虚拟DOM与Diff算法
前端
GoodTime44 分钟前
CodeBuddy IDE深度体验:全球首个产设研一体AI工程师的真实使用报告
前端·后端·架构
前端的日常1 小时前
说说你对 React Hook的闭包陷阱的理解,有哪些解决方案?
前端
t_hj1 小时前
Scrapy
前端·数据库·scrapy
小唐快跑1 小时前
🚀 2025 VS Code前端开发环境搭建指南:从入门到精通(含插件推荐+配置代码)
前端