You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现Netflix风格的RecyclerView Item侧滑交互效果?

RecyclerView 仿Netflix侧滑功能优化方案

一、当前代码的核心问题

  1. 动画时长设为0,完全没有过渡效果,用户感知不到滑动过程
  2. 仅通过静态修改translationX实现状态切换,未处理用户的触摸滑动手势,不是真正的交互侧滑
  3. 布局结构不合理:主内容容器和删除按钮用LinearLayout横向排列,导致删除按钮被主内容挤到屏幕外,无法正常显示
  4. 使用Handler.postDelayed(0)完全多余,主线程可直接执行逻辑
  5. 全局状态isOpenItems无法适配RecyclerView的ViewHolder复用机制,会导致条目状态混乱

二、布局结构调整

Netflix侧滑的核心是主内容层覆盖在操作按钮层之上,滑动主内容露出下方的操作按钮。修改布局为FrameLayout层叠结构:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    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:id="@+id/main_container"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <!-- 操作按钮层(固定在右侧下方) -->
    <FrameLayout
        android:id="@+id/delete_btn"
        android:layout_width="@dimen/dp_56"
        android:layout_height="match_parent"
        android:layout_gravity="end"
        android:background="@color/mirage">

        <ImageView
            android:layout_width="@dimen/dp_16"
            android:layout_height="@dimen/dp_16"
            android:layout_gravity="center"
            android:src="@drawable/icon_close"
            app:tint="@color/white" />

    </FrameLayout>

    <!-- 主内容层(覆盖在操作按钮之上,可滑动) -->
    <LinearLayout
        android:id="@+id/downloads_container"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/app_background"
        android:gravity="center"
        android:orientation="horizontal"
        android:paddingHorizontal="16dp"
        android:paddingVertical="7dp">

        <!-- 保留原有的图片、文本、进度条等内容 -->
        <com.google.android.material.card.MaterialCardView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:cardCornerRadius="10dp"
            app:cardElevation="0dp"
            app:strokeColor="@color/transparent">

            <ImageView
                android:id="@+id/film_image"
                android:layout_width="128dp"
                android:layout_height="70dp"
                android:scaleType="centerCrop"
                app:srcCompat="@drawable/test_img_hor" />

        </com.google.android.material.card.MaterialCardView>

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom"
            android:layout_marginStart="@dimen/dp_10"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:id="@+id/film_name"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:fontFamily="@font/inter_semi_bold"
                android:maxLines="2"
                android:textColor="@color/white"
                android:textSize="14sp"
                tools:text="Название фильма" />

            <TextView
                android:id="@+id/duration"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:fontFamily="@font/inter_regular"
                android:textColor="@color/neutral"
                android:textSize="12sp"
                tools:text="13 мин" />

            <TextView
                android:id="@+id/download_state"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:fontFamily="@font/inter_regular"
                android:textColor="@color/light_gray"
                android:textSize="12sp"
                tools:text="Загружено" />

            <TextView
                android:id="@+id/downloaded_bytes"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:fontFamily="@font/inter_regular"
                android:textColor="@color/light_gray"
                android:textSize="12sp"
                tools:text="Загружено" />

        </LinearLayout>

        <FrameLayout
            android:id="@+id/progress_container"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content">

            <com.google.android.material.progressindicator.CircularProgressIndicator
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:progress="100"
                app:indicatorColor="@color/white"
                app:indicatorSize="@dimen/dp_32"
                app:trackColor="@color/transparent" />

            <com.google.android.material.progressindicator.CircularProgressIndicator
                android:id="@+id/progress_bar"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:progress="50"
                app:indicatorColor="@color/white"
                app:indicatorSize="30dp"
                app:trackColor="@color/transparent"
                app:trackThickness="@dimen/sp_16" />

        </FrameLayout>

        <FrameLayout
            android:id="@+id/play_pause_btn"
            android:layout_width="@dimen/dp_40"
            android:layout_height="@dimen/dp_40"
            android:visibility="gone">

            <ImageView
                android:id="@+id/download_state_image"
                android:layout_width="@dimen/dp_20"
                android:layout_height="@dimen/dp_20"
                android:layout_gravity="center"
                android:src="@drawable/icon_pause" />

        </FrameLayout>

    </LinearLayout>

</FrameLayout>

三、实现触摸滑动交互(核心逻辑)

给主内容容器添加触摸监听,处理滑动手势,动态更新位置,并在滑动结束时自动判断展开/收起:

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val binding = holder.binding
    val item = getItem(position)
    val deleteBtnWidth = Func.dpToPx(fragment.context, 56f)
    
    // 初始化条目状态
    var currentTranslationX = if (item.isSwipeOpen) -deleteBtnWidth else 0f
    binding.downloads_container.translationX = currentTranslationX

    var startX = 0f
    binding.downloads_container.setOnTouchListener { _, event ->
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                startX = event.rawX
                true
            }
            MotionEvent.ACTION_MOVE -> {
                val deltaX = event.rawX - startX
                // 限制滑动范围:0到 -deleteBtnWidth(向左滑最大距离为删除按钮宽度)
                val newTranslationX = currentTranslationX + deltaX
                binding.downloads_container.translationX = newTranslationX.coerceIn(-deleteBtnWidth, 0f)
                true
            }
            MotionEvent.ACTION_UP -> {
                val finalTranslationX = binding.downloads_container.translationX
                // 滑动超过一半则展开,否则收起
                val shouldOpen = finalTranslationX < -deleteBtnWidth / 2
                val targetTranslationX = if (shouldOpen) -deleteBtnWidth else 0f
                
                // 执行平滑动画
                ObjectAnimator.ofFloat(binding.downloads_container, "translationX", targetTranslationX)
                    .setDuration(200)
                    .setInterpolator(AccelerateDecelerateInterpolator())
                    .start()
                
                currentTranslationX = targetTranslationX
                item.isSwipeOpen = shouldOpen // 更新条目状态
                true
            }
            else -> false
        }
    }

    // 删除按钮点击逻辑
    binding.delete_btn.setOnClickListener {
        // 执行删除操作,例如移除数据并刷新列表
        removeItem(position)
        // 收起侧滑
        closeSwipe(binding)
        item.isSwipeOpen = false
    }
}

// 封装收起侧滑的动画函数
private fun closeSwipe(binding: ItemDownloadsBinding) {
    ObjectAnimator.ofFloat(binding.downloads_container, "translationX", 0f)
        .setDuration(200)
        .setInterpolator(AccelerateDecelerateInterpolator())
        .start()
}

四、状态管理优化

RecyclerView的ViewHolder复用会导致全局状态混乱,需给每个数据条目添加独立的侧滑状态:

// 数据类添加侧滑状态字段
data class DownloadItem(
    val filmName: String,
    val duration: String,
    val downloadState: String,
    val downloadedBytes: String,
    // 其他业务字段...
    var isSwipeOpen: Boolean = false // 记录当前条目是否展开侧滑
)

五、额外优化点

  1. 滚动时自动收起侧滑:避免RecyclerView滚动时,展开的侧滑条目状态混乱
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        super.onScrollStateChanged(recyclerView, newState)
        if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
            // 遍历所有条目,收起已展开的侧滑
            for (i in 0 until itemCount) {
                val item = getItem(i)
                if (item.isSwipeOpen) {
                    item.isSwipeOpen = false
                    notifyItemChanged(i)
                }
            }
        }
    }
})
  1. 边缘阻力效果:滑动到极限位置时,增加阻力让交互更自然,可在ACTION_MOVE中调整deltaX的系数:
MotionEvent.ACTION_MOVE -> {
    val deltaX = event.rawX - startX
    var adjustedDelta = deltaX
    val currentX = binding.downloads_container.translationX
    // 左滑到极限时增加阻力
    if (currentX <= -deleteBtnWidth && deltaX < 0) {
        adjustedDelta = deltaX * 0.2f
    }
    // 右滑到极限时增加阻力
    else if (currentX >= 0 && deltaX > 0) {
        adjustedDelta = deltaX * 0.2f
    }
    val newTranslationX = currentTranslationX + adjustedDelta
    binding.downloads_container.translationX = newTranslationX.coerceIn(-deleteBtnWidth, 0f)
    true
}

内容的提问来源于stack exchange,提问作者123 321

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 13:58:10