Todd Hoatson
Todd Hoatson

Reputation: 364

How do I get Accordion-like behavior in a RecycleView?

I wanted to use the Kivy Accordion widget in my python app, but I couldn't get it to work right - the accordion items would expand or contract to exactly fill the space in the window. That's when I realized I had a bigger problem: the number of accordion items could increase indefinitely, but my accordion had no scroll bar. So, after some searching, I found the RecycleView stuff in Kivy. After looking at the online documentation, as well as the demo code in \Python311\share\kivy-examples\widgets\recycleview, I decided to grab basic_data.py and make some modifications.

Here's my code:

from random import sample, randint
from string import ascii_lowercase
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.label import Label
from kivy.uix.recycleview.views import RecycleKVIDsDataViewBehavior

kv = """
<Row>:
    heading: ''
    BoxLayout:
        orientation: 'horizontal'
        Button:
            background_normal: ''
            background_color: 0.3, 0.4, 0.3, 1
            text: root.heading
            on_press: root.expand()

<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):
    is_expanded = ObjectProperty(None)
    heading = StringProperty(None)
    label = ObjectProperty(None)
    orientation = 'vertical'

    def expand(self):
        print(f"Row with heading '{self.heading}' has been expanded!")
        if self.is_expanded:
            self.is_expanded = False
            self.remove_widget(self.label)
            self.height -= self.label.height
        else:
            self.is_expanded = True
            self.label = Label(text='Expanded data ...', markup=True)
            self.add_widget(self.label)
            self.height += self.label.height


class Test(BoxLayout):

    def populate(self):
        self.rv.data = [
            {'heading': date.today().__str__() + '   ' + str(randint(0, 2000))}
            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):
        self.rv.data.insert(0, {
            'name.text': heading or 'default heading', 'heading': 'unknown'})

    def update(self, heading):
        if self.rv.data:
            self.rv.data[0]['name.text'] = 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()

#   def expand(self, row):
#        row.expand()


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

When I ran the code, I was able to populate the RecycleView. I had changed the definition of Row to include a button for expanding and contracting an individual Accordion item. When expanded, a Label widget is added to the Row. When contracted, the Label is removed. So far so good...

But when expanded, the Row item did not increase in height - at least at first. So the Button and Label simply became more compact: Expanded but compacted

I wanted the Row to expand, so the Label would appear below the Button, each at normal size.

As I continued to play with it, I realized that it was actually inconsistent in its behavior: Expanded inconsistently

One time the Label would not be shown at all, but the button would be twice as high. Another time it would appear as I had hoped! I have no idea how to guarantee consistent, hoped-for behavior.

I tried making changes to the code...

I made a small change at the App level to hang on to the main layout, so I could get access to the RecycleView later:

class TestApp(App):
    testLayout = ObjectProperty(None)

    def build(self):
        testLayout = Test()
        return testLayout

Then I changed the code for expanding the Row:

def expand(self):
    if self.is_expanded:
        self.is_expanded = False
        self.remove_widget(self.label)
        self.height -= self.label.height
        print(f"Row with heading '{self.heading}' has been contracted!")
    else:
        self.is_expanded = True
        self.label = Label(text='Expanded data ...', markup=True, size_hint_y=None, height=dp(50))
        self.add_widget(self.label)
        self.height += self.label.height
        print(f"Row with heading '{self.heading}' has been expanded!")
        print(f"Label height '{self.label.height}' Row height '{self.height}' ")
    # Use Clock to schedule refresh after the layout update
    Clock.schedule_once(self.refresh_view, 0)

def refresh_view(self, *args):
    rv = self.get_recycleview_parent()
    if rv:
        rv.refresh_from_data()

def get_recycleview_parent(self):
    parent = self.parent
    while parent and not isinstance(parent, RecycleView):
        parent = parent.parent
    return parent

I tried adding the label height to the Row height when expanded, but subtracting the label height from the Row height when contracted. Then I got the RecycleView from the main layout and called refresh_from_data() to readjust everything to the new reality.

That didn't work. I couldn't get the RecycleView that way.

That's when I added the call to get_recycleview_parent() to get the RecycleView. Now, clicking on a row causes multiple, random rows to expand, inconsistently, and the program hangs.

Can anyone offer suggestions on how to get the desired behavior (in contrast to the ineffective guesses from Copilot)?

Upvotes: 0

Views: 19

Answers (0)

Related Questions