behavior of "refresh_from_data" in RecycleView

180 views
Skip to first unread message

Abc

unread,
Jul 1, 2023, 12:45:07 PM7/1/23
to Kivy users support

Hello all,
I am currently working on a small demonstrator using RecycleView. I’m having a bit of a hard time understanding how the data manipulation really works. I think I know that you should only use rv.data when you want to manipulate the content of widgets inside a “view”. If a “view” contains more complex widgets, it can be a bit difficult to use only this data list, but that’s another story.
The point is that it seems that sometimes it is unavoidable to use the refresh_from_data method, especially when widget contents change, views are deleted, views are moved, etc…. As soon as I do that, however, a somewhat strange behavior shows up. Below is the extended standard example for selectable rows in a recycleview that shows this behavior.

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.uix.boxlayout import BoxLayout from kivy.properties import BooleanProperty, StringProperty from kivy.uix.recycleboxlayout import RecycleBoxLayout from kivy.uix.behaviors import FocusBehavior from kivy.uix.recycleview.layout import LayoutSelectionBehavior Builder.load_string(''' <SelectableLayout>: orientation: "horizontal" # 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 Label: id: lab text: root.text Spinner: id: rwflag text: root.rwtext values: ["W", "R"] size_hint_x: None width: self.height <RV>: # Optional: ScrollView attributes bar_width: 5 bar_color: 1, 0, 0, 1 # red bar_inactive_color: 0, 0, 1, 1 # blue effect_cls: "ScrollEffect" scroll_type: ['bars', 'content'] viewclass: 'SelectableLayout' SelectableRecycleBoxLayout: default_size: None, dp(26) default_size_hint: 1, None size_hint_y: None height: self.minimum_height orientation: 'vertical' multiselect: False touch_multiselect: False <Main>: orientation: "vertical" Button: text: "update" size_hint_y: None height: 48 on_release: rv.update_cont() RV: id: rv ''') class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, RecycleBoxLayout): ''' Adds selection and focus behaviour to the view. ''' class SelectableLayout(RecycleDataViewBehavior, BoxLayout): ''' Add selection support to the Label ''' index = None selected = BooleanProperty(False) selectable = BooleanProperty(True) text = StringProperty("") rwtext = StringProperty("") def refresh_view_attrs(self, rv, index, data): ''' Catch and handle the view changes ''' self.index = index return super(SelectableLayout, self).refresh_view_attrs( rv, index, data) def on_touch_down(self, touch): ''' Add selection on touch down ''' if super(SelectableLayout, self).on_touch_down(touch): return self.parent.select_with_touch(self.index, touch) 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 class RV(RecycleView): def __init__(self, **kwargs): super(RV, self).__init__(**kwargs) self.data = [{'text': str(x), "rwtext": "W"} for x in range(1,100)] self.data.insert(0, {'text': 'aaa', "rwtext": "W"}) self.data.append({'text': 'zzz', "rwtext": "W"}) def update_cont(self): for c in self.children[0].children: if c.selected: print (" index: ", str(c.index), " label: ", c.ids.lab.text, " spinner: ", c.ids.rwflag.text) break self.refresh_from_data() class Main(BoxLayout): pass class TestApp(App): title = 'Kivy RecycleView Demo' def build(self): return Main() if __name__ == '__main__': TestApp().run()

Two widgets per view were used. Both text properties are reflected in the data list. However, as soon as I call refresh_from_data, the widget content is mirrored from the spinner button to the other end of the visible view. Funnily enough, though, the label content stays where it belongs.
Maybe someone can help me to understand and maybe there is a simple workaround for this.

the kivy version is 2.1.0, python is 3.11.2
I think a similar question is discussed in:

I also tried several things without success, including writing with a spinners callback directly to rv.data to force a sync between widget and data content. Probably I did it wrong, so I leave it to you to guide me on the right track.

thanks in advance

elli...@cox.net

unread,
Jul 1, 2023, 6:06:59 PM7/1/23
to kivy-...@googlegroups.com

Below find an example of a simple recycleview, where I attempt to explain some of the behavior.

 

The reason your spinner is not working properly is because you are relying on the widget to maintain state, this won’t work.  It helps to understand how RecycleView works:   Every time a widget is visible in the view, the visible widget will apply the list of attributes from the items in the data list, to that widget.  Of course, the binding applies, so keeping a selected state in the widget doesn't work.  For the spinner to work properly you need to save the state of the spinner outside of the widget.  You want the (recycled) widget to be set/reset when the widget is used for another data item so you have to save that selected state outside of the widget.   One possible solution is to edit the items in data(the RecycleView data attribute), but that could trigger new dispatches and so reset which widgets displays which items, and cause trouble.  The preferred solution is to save the widget state to a different list property, and just make the widget lookup that property when the widget’s key is updated.

 

 

 

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.recycleview import RecycleView

from kivy.uix.boxlayout import BoxLayout
from kivy.properties import StringProperty, ListProperty

kv =
'''
<TwoButtons>:
# This class is used as the viewclass in the RecycleView
# The means this widget will be instanced to view one element of data from the data list.
# The RecycleView data list is a list of dictionaries.  The keys in the dictionary specify the
# attributes of the widget.
    Button:
        text: root.left_text
        on_release: print(f'Button {self.text} pressed')
    Button:
        text: root.right_text
        on_release: print(f'Button {self.text} pressed')

BoxLayout:
    orientation: 'vertical'
    Button:
        size_hint_y: None
        height: 48
        text: 'Add widget to RV list'
        on_release: rv.add()

    RV:                          # A Reycleview
        id: rv
        viewclass: 'TwoButtons'  # The view class is TwoButtons, defined above.
        data: self.rv_data_list  # the data is a list of dicts defined below in the RV class.
        scroll_type: ['bars', 'content']
        bar_width: 10
        RecycleBoxLayout:       
            # This layout is used to hold the Recycle widgets
            default_size: None, dp(48)   # This sets the height of the BoxLayout that holds a TwoButtons instance.


            default_size_hint: 1, None
            size_hint_y: None

            height: self.minimum_height   # To scroll you need to set the layout height.
            orientation: 'vertical'
'''


class TwoButtons(BoxLayout):  # The viewclass definitions, and property definitions.
   
left_text = StringProperty()
    right_text = StringProperty()


class RV(RecycleView):
    rv_data_list = ListProperty() 
# A list property is used to hold the data for the recycleview, see the kv code

   
def __init__(self, **kwargs):
       
super().__init__(**kwargs)
       
self.rv_data_list = [{'left_text': f'Left {i}', 'right_text': f'Right {i}'} for i in range(2)]
       
# This list comprehension is used to create the data list for this simple example.
        # The data created looks like:
        # [{'left_text': 'Left 0', 'right_text': 'Right 0'}, {'left_text': 'Left 1', 'right_text': 'Right 1'},
        # {'left_text': 'Left 2', 'right_text': 'Right 2'}, {'left_text': 'Left 3'},...
        # notice the keys in the dictionary correspond to the kivy properties in the TwoButtons class.
        # The data needs to be in this kind of list of dictionary formats.  The RecycleView instances the
        # widgets, and populates them with data from this list.

   
def add(self):
        l =
len(self.rv_data_list)
       
self.rv_data_list.extend(
            [{
'left_text': f'Added Left {i}', 'right_text': f'Added Right {i}'} for i in range(l, l + 1)])


class RVTwoApp(App):

   
def build(self):
       
return Builder.load_string(kv)


RVTwoApp().run()

--
You received this message because you are subscribed to the Google Groups "Kivy users support" group.
To unsubscribe from this group and stop receiving emails from it, send an email to kivy-users+...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/kivy-users/bcdefb0a-8c4d-4fe2-94f9-4cd5f945bdddn%40googlegroups.com.

ElliotG

unread,
Jul 1, 2023, 6:23:15 PM7/1/23
to Kivy users support
Here is an example that I shared with another users.  you will notice in this example, that the state of the widgets is stored in the ListProperty app.button_state.  You could use a similar concept to store the state of the spinner outside of the widget.

from kivymd.app import MDApp
from kivy.lang.builder import Builder
from kivymd.uix.behaviors.toggle_behavior import MDToggleButton
from kivymd.uix.button.button import MDRectangleFlatButton
from kivymd.uix.recycleview import MDRecycleView
from kivy.properties import NumericProperty, ListProperty, ObjectProperty

kv = """
<PageButton>:
size_hint_x: None
width: 200
state: app.button_state[int(self.text)] if self.text else 'normal'
on_press:
if self.state == 'down': self.rv.update_button_state(int(self.text))
if self.state == 'normal': self.state = 'down'

<PaginationRV>
viewclass: 'PageButton'
RecycleBoxLayout:
padding: dp(10)
spacing: dp(5)
default_size: dp(48), None
default_size_hint: None, 1
size_hint: None, None
width: self.minimum_width
height: dp(48)
orientation: 'horizontal'

BoxLayout:
orientation: 'vertical'
MDLabel:
text: 'Pagination Example'
size_hint_y: None
height: 30
halign: 'center'
"""


class PageButton(MDRectangleFlatButton, MDToggleButton):
rv = ObjectProperty() # hold the recycleview for easy access


class PaginationRV(MDRecycleView):
page = NumericProperty(1)
count_pages = NumericProperty()

def __init__(self, **kwargs):
super().__init__(**kwargs)
self.data = [{"text": str(i), 'rv': self} for i in range(1, self.count_pages + 1)]
app = MDApp.get_running_app()
app.button_state = [None] + ['normal'] * self.count_pages
app.button_state[self.page] = 'down'

def update_button_state(self, button_number):
app = MDApp.get_running_app()
app.button_state[self.page] = 'normal'
app.button_state[button_number] = 'down'
self.page = button_number

def on_page(self, obj, value):
print(f'page {value} is the current page')


class App(MDApp):
button_state = ListProperty() # holds the state of the buttons, indexed by button text

def build(self):
return Builder.load_string(kv)

def on_start(self):
self.root.add_widget(PaginationRV(page=5, count_pages=1000))


if __name__ == "__main__":
App().run()



Abc

unread,
Jul 2, 2023, 3:54:02 PM7/2/23
to Kivy users support

Thanks for your fast response. I think I'm a step further. Attached you'll find a version which tries to update the data list when the text in the spinner button changes. This now also works when I press the "update" button, that is, call the "refresh_from_data()" method. However, the content of the spinner button widget itself that I previously changed seems to remain in that state. Since the "data" list is not synchronized with the view widget list, the changed state now appears in a different row. However, the content of the row I changed is correct. When refresh_from_data() is called multiple times, the content of the incorrect row is moved to another row, but this is somehow to be expected.

I have the impression I need to reset all the widget content manually (which is the idea from ElliotG I think) .. but I'm running out of ideas a bit. Perhaps you have a simple solution to solve this problem. Thanks again ..
rv_v2_rfd_spinner_event.py

Surfbat International

unread,
Jul 2, 2023, 8:32:22 PM7/2/23
to kivy-...@googlegroups.com
I need the process for package to ios 

Robert

unread,
Jul 2, 2023, 10:24:20 PM7/2/23
to Kivy users support
> I need the process for package to ios 


Not a lot of kivy-ios users here, if you need help try Discord https://discord.gg/MbqVbSnY

Abc

unread,
Jul 3, 2023, 9:04:00 AM7/3/23
to Kivy users support

One step further again. I have now adjusted the code so that a “refresh_form_data” no longer causes collateral damages .. call it a solution. Probably there are easier ways to achieve this. If you have any thoughts on this, I would be very grateful.

Just briefly to document this one possible solution of the problem:

  1. as ElliotG already described very well, you should not want to change widgets directly, because a “refresh_form_data” messes up the list “data” and the children order. This is also necessary if the number of rows is larger than the visible area. Nonetheless, in this special case I need to manipulate the widget contents manually — see below.

  2. the “text-change” event of the spinner button was redirected to a new function. In this function, the corresponding record in “data” is set accordingly. However, you have to take care that the record is only set directly after the selection with the spinner button. Immediately after altering the record the other widgets must be updated manually with the contents of “data” (“refresh_from_data” apparently did not consider such a case). To prevent this from happening unnecessarily too often, “mask-event” is used. Also it helps to prevent recursions, because “text-change” events can occur again during this manual update.

  3. “refresh_from_data” can now be used without problems.

For a simple list this sounds a way too complicated, doesn’t it? But I think it is at least a solution …
.. here the code:

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.uix.boxlayout import BoxLayout from kivy.properties import BooleanProperty, StringProperty, ObjectProperty, ListProperty, NumericProperty from kivy.uix.recycleboxlayout import RecycleBoxLayout from kivy.uix.behaviors import FocusBehavior from kivy.uix.recycleview.layout import LayoutSelectionBehavior Builder.load_string(''' <SelectableLayout>: orientation: "horizontal" # 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 Label: id: lab text: root.text Spinner: id: rwflag text: root.rwtext values: ["W", "R"] size_hint_x: None width: self.height <RV>: # Optional: ScrollView attributes bar_width: 5 bar_color: 1, 0, 0, 1 # red bar_inactive_color: 0, 0, 1, 1 # blue effect_cls: "ScrollEffect" scroll_type: ['bars', 'content'] viewclass: 'SelectableLayout' data: self.mydata SelectableRecycleBoxLayout: default_size: None, dp(26) default_size_hint: 1, None size_hint_y: None height: self.minimum_height orientation: 'vertical' multiselect: False touch_multiselect: False <Main>: orientation: "vertical" Button: text: "update" size_hint_y: None height: 48 on_release: rv.update_cont() RV: id: rv ''') class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, RecycleBoxLayout): ''' Adds selection and focus behaviour to the view. ''' class SelectableLayout(RecycleDataViewBehavior, BoxLayout): ''' Add selection support to the Label ''' index = 0 selected = BooleanProperty(False) selectable = BooleanProperty(True) text = StringProperty("") rwtext = StringProperty("") rvv = ObjectProperty(None) def refresh_view_attrs(self, rv, index, data): ''' Catch and handle the view changes ''' self.index = index # create an event binding for the spinner button when the text changes # "isbound" helps to make the binding just once if data["rvv"] != None and not data["rvv"].mydata[index]["isbound"]: self.ids.rwflag.bind(text=data["rvv"].when_rwflag_changed) data["rvv"].mydata[index]["isbound"] = True return super(SelectableLayout, self).refresh_view_attrs( rv, index, data) def on_touch_down(self, touch): ''' Add selection on touch down ''' if super(SelectableLayout, self).on_touch_down(touch): #seems to be a touch on the spinner button #announce, that a "text change" event might happen self.rvv.mask_event = True print("touched: ", self.ids.rwflag.text) return self.parent.select_with_touch(self.index, touch) 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 class RV(RecycleView): mydata = ListProperty([]) act_i = NumericProperty(-1) def __init__(self, **kwargs): super().__init__(**kwargs) self.mydata = [] self.mydata = [{'text': str(x), "rwtext": "W", "rvv": self, "isbound": False} for x in range(1,100)] self.mydata.insert(0, {'text': 'aaa', "rwtext": "W", "rvv": self, "isbound": False}) self.mydata.append({'text': 'zzz', "rwtext": "W", "rvv": self, "isbound": False}) i = 0 for d in self.mydata: d["index"] = i i += 1 #define a global event detector self.mask_event = False def update_cont(self): for c in self.children[0].children: if c.selected: print ( "update: ", " index: ", str(c.index), " label: ", c.ids.lab.text, " spinner: ", c.ids.rwflag.text, " rvv: ", c.rvv) #break for d in self.mydata: if d["rwtext"] == "R": print ("##### ",d) #to prevent wrong rewrite disable the event detector "mask_event" #this need to be done before each "refresh_from_data" #"text-change" event might be triggered by "refresh_from_data" self.mask_event = False self.refresh_from_data() def when_rwflag_changed(self, obj, t): # callback function for the "text change" event # https://kivy.org/doc/stable/api-kivy.uix.spinner.html if self.mask_event: print ("spinner event: ", obj.parent.index, obj, t, self.mask_event) self.mydata[obj.parent.index]["rwtext"] = t self.mask_event = False #rewrite content of relevant widgets directly #"refresh_from_data" does not check e.g. spinner-button widgets (?!) for c in self.children[0].children: c.rwtext = self.mydata[c.index]["rwtext"] class Main(BoxLayout): pass class TestApp(App): title = 'Kivy RecycleView Demo' def build(self): return Main() if __name__ == '__main__': TestApp().run()

ElliotG

unread,
Jul 3, 2023, 3:18:55 PM7/3/23
to Kivy users support
Congratulations on getting things working. 

There are a number of ways this code can be simplified.
1) You are only allowing a single selection at a time.  You can remove all of the complex selection behavior classes, and related logic, including apply_selection().
2) You can combine a Label (or Layout) with ButtonBehavior, and remove the on_touch_down code.  The label(with ButtonBehavior) or spinner know when they are touched.
3) It's not clear to me that you need to use refresh_view_attrs.  You are storing the index in the data list, so can use that directly in the widget.  I don't think you need to do a bind on rwflag in this code, you could use the on_text event in the spinner.

Abc

unread,
Jul 4, 2023, 1:08:16 PM7/4/23
to Kivy users support
thanks for looking through .. these are valuable advices, which I will incorporate to optimize the code, but can you please be more specific on point 2)  .. sorry, didn't catch your idea. Maybe you have a link with examples ..

ElliotG

unread,
Jul 9, 2023, 10:50:25 AM7/9/23
to Kivy users support
Here is an example that uses ToggleButtonBehavior to provide on_press, and on_release events to a label, and a state attribute (normal or down).  I find adding Behavior simpler that adding touch events for many use cases..

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.behaviors import ToggleButtonBehavior
from kivy.uix.label import Label
from kivy.properties import ColorProperty

kv = """
<TouchLabel>:
canvas.before:
Color:
rgba: self.background_color
Rectangle:
pos: self.pos
size: self.size

AnchorLayout:
TouchLabel:
size_hint: None, None
size: dp(200), dp(48)
text: 'Touch me'
on_release:
self.background_color = 'red' if self.state == 'down' else 'black'
"""

class TouchLabel(ToggleButtonBehavior, Label):
background_color = ColorProperty('black')

class TouchApp(App):
def build(self):
return Builder.load_string(kv)

TouchApp().run()

Reply all
Reply to author
Forward
0 new messages