pythonkivykivy-recycleview

Properly set default selections with RecycleView (Python/Kivy)


I want to use Kivy's RecycleView to make a multiline scrollable selection list, but I need to set as selected some of the items by default. The user must still be able to unselect them, if they wish (I want to implement some sort of proposed choices).

Based on the example on Kivy documentation, here follows a minimal working code that presents my problem:

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.recycleview import RecycleView
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.label import Label
from kivy.properties import BooleanProperty
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleview.layout import LayoutSelectionBehavior

Builder.load_string('''
<SelectableLabel>:
    # Draw a background to indicate selection
    canvas.before:
        Color:
            rgba: (.0, 0.9, .1, .3) if self.selected else (0, 0, 0, 1)
        Rectangle:
            pos: self.pos
            size: self.size
<RV>:
    viewclass: 'SelectableLabel'
    SelectableRecycleBoxLayout:
        default_size: None, dp(56)
        default_size_hint: 1, None
        size_hint_y: None
        height: self.minimum_height
        orientation: 'vertical'
        multiselect: True
        touch_multiselect: True
''')

class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
                                 RecycleBoxLayout):
    ''' Adds selection and focus behaviour to the view. '''

class SelectableLabel(RecycleDataViewBehavior, Label):
    ''' Add selection support to the Label '''
    index = None
    selected = BooleanProperty(False)
    selectable = BooleanProperty(True)

    def refresh_view_attrs(self, rv, index, data):
        ''' Catch and handle the view changes '''
        self.index = index
        self.selected = rv.data[self.index]['selected']
        return super(SelectableLabel, self).refresh_view_attrs(
            rv, index, data)

    def on_touch_down(self, touch):
        ''' Add selection on touch down '''
        if super(SelectableLabel, self).on_touch_down(touch):
            return True
        if self.collide_point(*touch.pos) and self.selectable:
            return self.parent.select_with_touch(self.index, touch)

    def apply_selection(self, rv, index, is_selected):
        ''' Respond to the selection of items in the view. '''
        self.selected = is_selected # line X, the gaming change
        if is_selected:
            print("selection changed to {0}".format(rv.data[index]))
        else:
            print("selection removed for {0}".format(rv.data[index]))

class RV(RecycleView):
    def __init__(self, items, **kwargs):
        if 'selection' in kwargs:
            selection = kwargs['selection']
            del kwargs['selection']
        else:
            selection = [False]*len(items)
        super().__init__(**kwargs)
        self.data = [{'text': x, 'selected': selection[i]} \
            for i, x in enumerate(items)]

items = [
    "apple", "dog", "banana", "cat", "pear", "rat", 
    "pineapple", "bat", "pizza", "ostrich",
    "apple", "dog", "banana", "cat", "pear", "rat", 
    "pineapple", "bat", "pizza", "ostrich",
]

class TestApp(App):
    def build(self):
        return RV(items, 
            selection=[x[0] in ['p','a','r'] \
            for x in items]
        )

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

This code doesn't show the default items actually selected. While I was conducting a proper investigation, I notice that if I comment a single line in the apply_selection method (line X comment in code above), i.e., if I change it to # self.selected = is_selected, I finally can see all my items with default selections.

Problem is, as you should probably know, that's the instruction that allows the selection feature to happen (!), i.e., this line while commented wins me my desired default items, but I lose the ability to actually select/unselect items. I think that the is_selected parameter is some sort of event which somehow detects an actual click selection and, while instantiating the RV class, some other method unselect all items of the list after apply_selection comes to play.

I tried to look up into documentation, but I don't even know what to search for. I'm missing which method I should overwrite in order to make this default trick finally work together with selection. Any thoughts?


Solution

  • After some diggings, I drop the SelectableLabel approach and adopted Button to do the trick. I'm registering my working custom RecycleView here to others:

    from kivy.app import App
    from kivy.lang import Builder
    from kivy.uix.button import Button
    from kivy.uix.recycleview import RecycleView
    from kivy.properties import BooleanProperty
    from kivy.uix.gridlayout import GridLayout
    
    Builder.load_string('''
    <Item>:
        index: None
        on_release: root.parent.parent.apply_selection(self.index)
    <RV>:
        viewclass: 'Item'
        RecycleBoxLayout:
            default_size_hint: 1, None
            default_size: 0, dp(40)
            size_hint_y: None
            height: self.minimum_height
            orientation: 'vertical'
    ''')
    
    class Item(Button): # root
        selected = BooleanProperty(False)
    
    class RV(RecycleView):
        DEF_COLOR = [0, 0, 0, 1]
        SEL_COLOR = [.0, .4, .8, .4]
    
        def __init__(self, **kwargs):
            super(RV, self).__init__(**kwargs)
            self.data = []
    
        def apply_selection(self, index, sel=None):
            ''' Respond to the selection of items in the view. '''
            sel = not self.data[index]['selected'] if sel is None else sel
            self.data[index]['selected'] = sel 
            self.data[index]['background_color'] = RV.SEL_COLOR \
                if sel else RV.DEF_COLOR
            self.refresh_from_data()
            print("DEBUG:", self.get_selected_indices())
    
        def update(self, data_list, sel=False, default=None):
            if default is None:
                default = [sel]*len(data_list)
            self.data = [{
                'index': i, 
                'text': str(d), 
                'selected': default[i], 
                'background_normal': '',
                'background_color': RV.SEL_COLOR \
                    if default[i] else RV.DEF_COLOR,
            } for i, d in enumerate(data_list)]
            self.refresh_from_data()
    
        def none_selected(self, instance):
            self.update(list(d['text'] for d in self.data))
    
        def all_selected(self, instance):
            self.update(list(d['text'] for d in self.data), sel=True)
    
        def get_selected_indices(self):
            return list(d['index'] for d in self.data if d['selected'])
    
    class TestPage(GridLayout):
        def __init__(self, **kwargs):
            super().__init__(**kwargs)
            self.padding = [5]*4
            self.cols = 1
    
            # Usage example
            rv = RV(size_hint=(1, 0.8))
            items = [i for i in range(0, 100)]
            my_default_selections = [3, 8, 42]
            default = [i in my_default_selections for i \
                in range(len(items))]
            rv.update([f"item {str(i)}" for i in items], 
                default=default)
            self.add_widget(rv)
            # Access to funcionalities (example)
            btn_all = Button(text="Select All", size_hint=(1, 0.05))
            btn_all.bind(on_release=rv.all_selected)
            self.add_widget(btn_all)
            btn_none = Button(text="Select None", size_hint=(1, 0.05))
            btn_none.bind(on_release=rv.none_selected)
            self.add_widget(btn_none)
            self.btn_get = Button(size_hint=(1, 0.1))
            self.update()
            self.btn_get.bind(on_release=self.update)
            self.add_widget(self.btn_get)
    
        def update(self, *largs, **kwargs):
            self.btn_get.text='Click to update:\n' + \
                str(self.rv.get_selected_indices())
    
    
    if __name__ == '__main__':
        class TestApp(App):
            def build(self):
                return TestPage()
    
        TestApp().run()
    

    Now I can generate a RV instance with multiline selection that can:

    I'm still looking for improvements, though.