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

如何在Kivy的RecycleView中实现稳定的手风琴式展开/收起效果?

如何在Kivy的RecycleView中实现稳定的手风琴式展开/收起效果?

我来帮你搞定这个手风琴效果的问题!你遇到的混乱行为,核心原因其实是RecycleView的视图复用机制——它不会为每一条数据都单独创建一个Row实例,而是会回收滚动出屏幕的Row,用来显示新的数据。你之前把展开状态(is_expanded)存在Row实例里、直接修改实例高度的操作,都会因为视图复用被“带到”其他数据项上,导致随机展开、布局混乱的问题。

下面我给你一套稳定的解决方案,核心思路是把每个条目的展开状态和高度存在RecycleView的data中,让状态和数据绑定,而不是和复用的视图绑定。

修改后的完整代码

from random import randint
from datetime import date

from kivy.app import App
from kivy.lang import Builder
from kivy.properties import ObjectProperty, StringProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.recycleview.views import RecycleKVIDsDataViewBehavior
from kivy.metrics import dp

kv = """
<Row>:
    heading: ''
    is_expanded: False
    orientation: 'vertical'
    
    # 头部按钮
    Button:
        background_normal: ''
        background_color: 0.3, 0.4, 0.3, 1
        text: root.heading
        size_hint_y: None
        height: dp(56)
        on_press: root.toggle_expand()

    # 展开内容(默认隐藏)
    Label:
        text: 'Expanded data ...'
        size_hint_y: None
        height: dp(50)
        opacity: 1 if root.is_expanded else 0
        disabled: not root.is_expanded
        canvas.before:
            Color:
                rgba: 0.2, 0.3, 0.2, 1
            Rectangle:
                size: self.size
                pos: self.pos

<Test>:
    canvas:
        Color:
            rgba: 0.3, 0.3, 0.3, 1
        Rectangle:
            size: self.size
            pos: self.pos
    rv: rv
    orientation: 'vertical'
    GridLayout:
        cols: 3
        rows: 2
        size_hint_y: None
        height: dp(108)
        padding: dp(8)
        spacing: dp(16)
        Button:
            text: 'Populate list'
            on_press: root.populate()
        Button:
            text: 'Sort list'
            on_press: root.sort()
        Button:
            text: 'Clear list'
            on_press: root.clear()
        BoxLayout:
            spacing: dp(8)
            Button:
                text: 'Insert new item'
                on_press: root.insert(new_item_input.text)
            TextInput:
                id: new_item_input
                size_hint_x: 0.6
                hint_text: 'heading'
                padding: dp(10), dp(10), 0, 0
        BoxLayout:
            spacing: dp(8)
            Button:
                text: 'Update first item'
                on_press: root.update(update_item_input.text)
            TextInput:
                id: update_item_input
                size_hint_x: 0.6
                hint_text: 'new heading'
                padding: dp(10), dp(10), 0, 0
        Button:
            text: 'Remove first item'
            on_press: root.remove()

    RecycleView:
        id: rv
        scroll_type: ['bars', 'content']
        scroll_wheel_distance: dp(114)
        bar_width: dp(10)
        viewclass: 'Row'
        RecycleBoxLayout:
            default_size: None, dp(56)
            default_size_hint: 1, None
            size_hint_y: None
            height: self.minimum_height
            orientation: 'vertical'
            spacing: dp(2)
"""

Builder.load_string(kv)


class Row(RecycleKVIDsDataViewBehavior, BoxLayout):
    heading = StringProperty(None)
    is_expanded = ObjectProperty(False)
    index = None  # 记录当前行对应data的索引

    def toggle_expand(self):
        if self.index is None:
            return
        
        # 获取当前应用的RecycleView数据
        app = App.get_running_app()
        rv_data = app.root.rv.data
        current_data = rv_data[self.index]
        
        # 切换展开状态
        new_expand_state = not current_data['is_expanded']
        current_data['is_expanded'] = new_expand_state
        
        # 计算并更新行高:默认高度 + 展开内容高度(如果展开)
        default_height = dp(56)
        content_height = dp(50)
        current_data['height'] = default_height + content_height if new_expand_state else default_height
        
        # 仅刷新当前行的布局,避免全局刷新
        app.root.rv.refresh_view_layout(self.index, 0)

    # 重写视图刷新方法,从data中同步状态和高度
    def refresh_view_attrs(self, rv, index, data):
        self.index = index
        self.heading = data['heading']
        self.is_expanded = data.get('is_expanded', False)
        self.height = data.get('height', dp(56))
        return super().refresh_view_attrs(rv, index, data)


class Test(BoxLayout):
    rv = ObjectProperty(None)

    def populate(self):
        default_height = dp(56)
        self.rv.data = [
            {
                'heading': f"{date.today()}   {randint(0, 2000)}",
                'is_expanded': False,
                'height': default_height
            }
            for x in range(50)
        ]

    def sort(self):
        self.rv.data = sorted(self.rv.data, key=lambda x: x['heading'])

    def clear(self):
        self.rv.data = []

    def insert(self, heading):
        default_height = dp(56)
        self.rv.data.insert(0, {
            'heading': heading or 'default heading',
            'is_expanded': False,
            'height': default_height
        })

    def update(self, heading):
        if self.rv.data:
            self.rv.data[0]['heading'] = heading or 'default new heading'
            self.rv.refresh_from_data()

    def remove(self):
        if self.rv.data:
            self.rv.data.pop(0)


class TestApp(App):
    def build(self):
        return Test()


if __name__ == '__main__':
    TestApp().run()

关键修改点说明

  1. 状态与数据绑定
    把每个条目的is_expanded(展开状态)和height(行高)都存在RecycleView的data字典中,彻底避免视图复用导致的状态混乱。

  2. 正确的视图刷新方式
    使用refresh_view_layout(index, 0)仅刷新当前点击行的布局,而不是全局刷新整个列表,既高效又不会干扰其他行的状态。

  3. 稳定的内容显示控制
    展开内容的Label默认通过opacity属性隐藏/显示,而非动态添加/移除Widget,避免频繁操作Widget树带来的性能问题和布局抖动。

  4. 重写视图刷新逻辑
    通过refresh_view_attrs方法,每次视图复用时都从data中同步最新的状态和高度,确保每个行都能正确显示对应数据的状态。

这样修改后,你点击任何一行都只会修改对应数据项的状态,RecycleView会根据data中的配置正确渲染每一行,完全不会出现随机展开、布局混乱的问题,完美实现手风琴的预期效果。

备注:内容来源于stack exchange,提问作者Todd Hoatson

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.14 16:23:00