'Store toggle button states in the widget (Kivy)

I have a recycleview that contains BoxLayouts with two buttons, that share a group for each BoxLayout. I would like the buttons to function as the up and down votes buttons on StackOverflow. However, the issue at the moment is that the state of the buttons is not stored in the BoxLayout widget and this results in them showing up multiple times when they shouldn't for widgets that are not on the screen.

There is a similar question here: How can I keep my Kivy ToggleButton state when scrolling using a RecycleView?

But I have not been able to implement the same logic into my code, additionally, if one button is selected, the other cannot be (Just like not being able to upvote and downvote a question on StackOverflow at the same time) and the ability to not have any buttons selected at all.

I have included a minimal example below, where some lines are commented out of the .kv file so that it can still run but still with the issue. (The code is as similar to the question linked above as possible as my attempts have strayed away and haven't worked)

Any help will be greatly appreciated, I don't think that I'm miles off and I will continue to try different things

main.py

from random import randint

from kivy.app import App
from kivy.uix.label import Label
from kivy.clock import Clock
from kivy.graphics import Color, Rectangle
from kivy.uix.recycleview import RecycleView
from kivy.uix.boxlayout import BoxLayout
from kivy.properties import StringProperty, ListProperty

class NewPostGrid(BoxLayout):
    votes_ = StringProperty()
    message_id_ = StringProperty()
    text_ = StringProperty()
    group_ = StringProperty()
    _size = ListProperty()
    vote_state_up = StringProperty()
    vote_state_down = StringProperty()

    #def update_message_size(self, message_id, texture_size): # Updates the '_size' value in rv_data_list based on the texture_size
    #    App.get_running_app().rv_data_list[int(message_id)] = {**App.get_running_app().rv_data_list[int(message_id)], '_size':[0, texture_size[1]]}
    #    #print(App.get_running_app().rv_data_list)

class SizeLabel(Label):
    def __init__(self, *args, **kwargs):  
        Label.__init__(self, *args, **kwargs)  
        Clock.schedule_once(lambda dt: self.initialize_widget(), 0.002)

    def initialize_widget(self):
        self.canvas.before.add(Color(1, 1, 1, 0))  
        self.canvas.before.add(Rectangle(pos=self.pos, size=self.size))  
        self.text_size = self.size
        self.text = ''

class RV(RecycleView):
    message_id_num = 0

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        App.get_running_app().rv_data_list = []

    def generate_post(self): # This is only to test posts with different line height
        e = ['Test post ID: ', str(self.message_id_num)]
        for i in range(randint(1, 8)): e.append('\n')
        e.append('end of post')
        return "".join(e)

    def add(self):
        l = len(App.get_running_app().rv_data_list)
        text = self.generate_post()
        sl = SizeLabel(text=text)
        sl.texture_update()
        print(sl.text)
        App.get_running_app().rv_data_list.extend([{'message_id_': str(self.message_id_num),
                                                    'text_': text, '_size': sl.texture_size,
                                                    'group_': str(self.message_id_num), 'votes_': str(20), 'vote_state_up': 'down', 'vote_state_down': 'normal'}])
        self.message_id_num = self.message_id_num + 1

    def adjust_vote_state(self, id_):
        for d in self.data:
            if d['text_'] == id_.text_:
                d['state'] == id_.ids.button_up.state
                id_.state = id_.ids.button_up.state


class DemoApp(App):
    # One post format = {'message_id':0, 'text':'post_test_here','_size':[0,0], '_group':str(0), '_score':20}
    # Text fromat string = [font=Nunito-Bold.ttf][color=161616]Someone:[/color][/font]\n
    rv_data_list = ListProperty()

    def up_vote(self, button, mode): # Not part of the problem
        if button.state == 'down':
            if mode == 'all':
                print("+1 upvote for message index:" + str(button.parent.parent.message_id) + ' in all posts')
            else:
                print("+1 upvote for message index:" + str(button.parent.parent.message_id) + ' in top posts')
    
    def down_vote(self, button, mode): # Not part of the problem
        if button.state == 'down':
            if mode == 'all':
                print("-1 upvote for message index:" + str(button.parent.parent.message_id) + ' in all posts')
            else:
                print("-1 upvote for message index:" + str(button.parent.parent.message_id) + ' in top posts')





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

demo.kv

<NewPostGrid>:
    spacing: "6dp"
    message_id: root.message_id_
    BoxLayout:
        id: voting_menu
        orientation: "vertical"
        spacing: "2dp"
        size_hint: .2, None
        height: label.height
        ToggleButton:
            id: button_up
            #on_state: app.up_vote(self, 'all')
            group: root.group_
            #state: root.vote_state_up
            text: "UP"
            color: (1,1,1,1) if self.state=='normal' else (.8,0,0,1)
            font_size: "10dp"
            size_hint: 1, .3
            background_color: .2, .2, .2, 0
            #on_release: app.root.ids.rv.adjust_vote_state(root)
            canvas.before:
                Color:
                    rgba: (.1,.1,.1,1)
                RoundedRectangle:
                    pos: self.pos
                    size: self.size
                    radius: [6,]
            canvas:
                Color:
                    rgba: (.2,.2,.2,1)
                Line:
                    width: 1.4
                    rounded_rectangle:(self.x,self.y,self.width,self.height, 5)
        
        Label:
            id: vote_count
            text: root.votes_
            size_hint: 1, .4
            multiline: False

        ToggleButton:
            id: button_down
            #on_state: app.down_vote(self, 'all')
            group: root.group_
            #state: root.vote_state_down
            text: "DOWN"
            color: (1,1,1,1) if self.state=='normal' else (.8,0,0,1)
            font_size: "10dp"
            size_hint: 1, .3
            background_color: .2, .2, .2, 0
            canvas.before:
                Color:
                    rgba: (.1,.1,.1,1)
                RoundedRectangle:
                    pos: self.pos
                    size: self.size
                    radius: [6,]
            canvas:
                Color:
                    rgba: (.2,.2,.2,1)
                Line:
                    width: 1.4
                    rounded_rectangle:(self.x,self.y,self.width,self.height, 5)
    Label:
        id: label
        text: root.text_
        padding: "10dp", "12dp"
        size_hint: .9, None
        height: self.texture_size[1]
        font_size: "12dp"
        text_size: self.width, None
        color: 0,0,0,1
        multiline: True
        markup: True
        
        #on_texture_size: root.update_message_size(root.message_id, self.texture_size)

        pos: self.x, self.y

        canvas.before:
            Color:
                rgba: (0.8, 0.8, 0.8, 1)
            RoundedRectangle:
                size: self.texture_size
                radius: [5, 5, 5, 5]
                pos: self.x, self.y
        canvas:
            Color:
                rgba:0.8,0,0,1
            Line:
                width: 1.4
                rounded_rectangle:(self.x,self.y,self.width,self.height, 5)
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: 'NewPostGrid'  # The view class is TwoButtons, defined above.
        data: app.rv_data_list  # the data is a list of dicts defined below in the RV class.
        scroll_type: ['bars', 'content']
        bar_width: 2
        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.
            key_size: '_size'  # key for the datalist
            default_size_hint: 1, None
            size_hint_y: None
            spacing: '20dp'
            height: self.minimum_height   # To scroll you need to set the layout height.
            orientation: 'vertical'
            padding: ['10dp', '20dp']


Solution 1:[1]

I think adjusting your demo.kv to modify the data when a ToggleButton state is changed will work. Here is a modified version of your demo.kv that I think will work:

<NewPostGrid>:
    spacing: "6dp"
    message_id: root.message_id_
    BoxLayout:
        id: voting_menu
        orientation: "vertical"
        spacing: "2dp"
        size_hint: .2, None
        height: label.height
        ToggleButton:
            id: button_up
            state: root.vote_state_up
            #on_state: app.up_vote(self, 'all')
            on_state:
                root.vote_state_up = self.state
                app.rv_data_list[int(root.message_id_)]['vote_state_up'] = self.state
            group: root.group_
            #state: root.vote_state_up
            text: "UP"
            color: (1,1,1,1) if self.state=='normal' else (.8,0,0,1)
            font_size: "10dp"
            size_hint: 1, .3
            background_color: .2, .2, .2, 0
            #on_release: app.root.ids.rv.adjust_vote_state(root)
            canvas.before:
                Color:
                    rgba: (.1,.1,.1,1)
                RoundedRectangle:
                    pos: self.pos
                    size: self.size
                    radius: [6,]
            canvas:
                Color:
                    rgba: (.2,.2,.2,1)
                Line:
                    width: 1.4
                    rounded_rectangle:(self.x,self.y,self.width,self.height, 5)
        
        Label:
            id: vote_count
            # text: root.votes_
            text: button_up.state + ' ' + button_down.state
            size_hint: 1, .4
            multiline: False

        ToggleButton:
            id: button_down
            state: root.vote_state_down
            #on_state: app.down_vote(self, 'all')
            on_state:
                root.vote_state_down = self.state
                app.rv_data_list[int(root.message_id_)]['vote_state_down'] = self.state
            group: root.group_
            #state: root.vote_state_down
            text: "DOWN"
            color: (1,1,1,1) if self.state=='normal' else (.8,0,0,1)
            font_size: "10dp"
            size_hint: 1, .3
            background_color: .2, .2, .2, 0
            canvas.before:
                Color:
                    rgba: (.1,.1,.1,1)
                RoundedRectangle:
                    pos: self.pos
                    size: self.size
                    radius: [6,]
            canvas:
                Color:
                    rgba: (.2,.2,.2,1)
                Line:
                    width: 1.4
                    rounded_rectangle:(self.x,self.y,self.width,self.height, 5)
    Label:
        id: label
        text: root.text_
        padding: "10dp", "12dp"
        size_hint: .9, None
        height: self.texture_size[1]
        font_size: "12dp"
        text_size: self.width, None
        color: 0,0,0,1
        multiline: True
        markup: True
        
        #on_texture_size: root.update_message_size(root.message_id, self.texture_size)

        pos: self.x, self.y

        canvas.before:
            Color:
                rgba: (0.8, 0.8, 0.8, 1)
            RoundedRectangle:
                size: self.texture_size
                radius: [5, 5, 5, 5]
                pos: self.x, self.y
        canvas:
            Color:
                rgba:0.8,0,0,1
            Line:
                width: 1.4
                rounded_rectangle:(self.x,self.y,self.width,self.height, 5)
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: 'NewPostGrid'  # The view class is TwoButtons, defined above.
        data: app.rv_data_list  # the data is a list of dicts defined below in the RV class.
        scroll_type: ['bars', 'content']
        bar_width: 2
        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.
            key_size: '_size'  # key for the datalist
            default_size_hint: 1, None
            size_hint_y: None
            spacing: '20dp'
            height: self.minimum_height   # To scroll you need to set the layout height.
            orientation: 'vertical'
            padding: ['10dp', '20dp']

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 John Anderson