Search code examples
pythonkivydropdown

Cannot get correct item selection from DropDown widget in Kivy


In my Kivy app, one of the text inputs triggers the opening of a DropDown widget when on_focus. The textinput is part of a custom BoxLayout IngredientRow which I dinamically add to the screen on the press of a button.

What I want is to fill the textinput with the text of the button selected from the DropDown. This works for the first IngredientRow. However, when I add new rows, selecting an item from the DropDown in a row different from the first, will fill the textinput from the first row. See below a minimal working example:

The py file:

from kivy.app import App
from kivy.factory import Factory
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.dropdown import DropDown
from kivy.uix.screenmanager import Screen, ScreenManager
from kivy.uix.textinput import TextInput


class DelIngButton(Button):
    pass
class DropListButton(Button):
    def __init__(self, **kwargs):
        super(DropListButton, self).__init__(**kwargs)
        self.bind(on_release=lambda x: self.parent.parent.select(self.text))
class IngredientRow(BoxLayout):
    pass
class MeasureDropDown(DropDown):
    pass

####################################
class AddWindow(Screen):
    def __init__(self, **kwargs):
        super(AddWindow, self).__init__(**kwargs)

        self.DropDown = MeasureDropDown()

    def addIngredient(self, instance): #adds a new IngredientRow
        row = instance.parent
        row.remove_widget(row.children[0])
        row.add_widget(Factory.DelIngButton(), index=0)
        self.ingsGrid.add_widget(Factory.IngredientRow(), index=0)


class WMan(ScreenManager):
    def __init__(self, **kwargs):
        super(WMan, self).__init__(**kwargs)

kv = Builder.load_file("ui/layout.kv")

class RecipApp(App):
    def build(self):
        return kv

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

and the kv file:

#:set text_color 0,0,0,.8

#:set row_height '35sp'

#:set main_padding ['10sp', '10sp']
#:set small_padding ['5sp', '5sp']


<DropListButton>: # Button for custom DropDown
    color: text_color
    background_normal: ''

<DelIngButton>: # Button to delete row
    text: '-'
    size_hint: None, None
    height: row_height
    width: row_height
    on_release: self.parent.parent.remove_widget(self.parent)

<MeasureDropDown>:
    id: dropDown
    DropListButton:
        size_hint: 1, None
        height: row_height
        text: "g"
    DropListButton:
        size_hint: 1, None
        height: row_height
        text: "Kg"
    TextInput:
        size_hint: 1, None
        height: row_height
        hint_text: 'new'

<IngredientRow>:
    orientation: 'horizontal'
    size_hint: 1, None
    height: row_height
    spacing: '5sp'
    TextInput:
        id: ing
        hint_text: 'Ingredient'
        multiline: False
        size_hint: .6, None
        height: row_height
    TextInput:
        id: quant
        hint_text: 'Quantity'
        multiline: False
        size_hint: .2, None
        height: row_height
    TextInput:
        id: measure
        hint_text: 'measure'
        size_hint: .2, None
        height: row_height
        on_focus:
            app.root.ids.add.DropDown.open(self) if self.focus else app.root.ids.add.DropDown.dismiss(self)
            app.root.ids.add.DropDown.bind(on_select=lambda self, x: setattr(app.root.ids.add.ingredientRow.children[1], 'text', x))
    Button:
        id: addIng
        text: "+"
        size_hint: None, None
        height: row_height
        width: row_height
        on_release: app.root.ids.add.addIngredient(self)


<MainScrollView@ScrollView>:
    size_hint: 1, None
    scroll_type: ['bars', 'content']

##################
# Windows
##################

WMan:
    AddWindow:
        id: add

<AddWindow>:
    name: 'add'
    ingsGrid: ingsGrid
    ingredientRow: ingredientRow

    MainScrollView:
        height: self.parent.size[1]
        GridLayout:
            cols:1
            size_hint: 1, None
            pos_hint: {"top": 1}
            height: self.minimum_height
            padding: main_padding
            StackLayout:
                id: ingsGrid
                size_hint: 1, None
                height: self.minimum_height
                orientation: 'lr-tb'
                padding: small_padding
                IngredientRow:
                    id: ingredientRow

I understand the problem is with the following part of the code:

on_select=lambda self, x: setattr(app.root.ids.add.ingredientRow.children[1], 'text', x)

as this will always call the first IngredientRow. However, I could not figure out how to refer to the IngredientRow where the DropDown is called.


Solution

  • Combining my first answer with code to handle the TextInput in the MeasureDropDown:

    from kivy.app import App
    from kivy.factory import Factory
    from kivy.lang import Builder
    from kivy.properties import BooleanProperty
    from kivy.uix.boxlayout import BoxLayout
    from kivy.uix.button import Button
    from kivy.uix.dropdown import DropDown
    from kivy.uix.screenmanager import Screen, ScreenManager
    from kivy.uix.textinput import TextInput
    
    
    class DelIngButton(Button):
        pass
    
    
    class DropListButton(Button):
        def __init__(self, **kwargs):
            super(DropListButton, self).__init__(**kwargs)
            self.bind(on_release=lambda x: self.parent.parent.select(self.text))
    
    
    class DropListTextInput(TextInput):
        # Provides a couple needed behaviors
    
        def on_focus(self, *args):
            if self.focus:
                self.dropDown.selection_is_DLTI = True
            else:
                self.dropDown.selection_is_DLTI = False
    
        def on_text_validate(self, *args):
            self.dropDown.selection_is_DLTI = False
    
            # put the text from this widget into the TextInput that the DropDown is attached to
            self.dropDown.attach_to.text = self.text
    
            # dismiss the DropDown
            self.dropDown.dismiss()
    
    
    class IngredientRow(BoxLayout):
        def __init__(self, **kwargs):
            super(IngredientRow, self).__init__(**kwargs)
            self.dropdown = MeasureDropDown()
    
        def handle_focus(self, ti):
            # handle on_focus event for the measure TextInput
            if ti.focus:
                # open DropDown if the TextInput gets focus
                self.dropdown.open(ti)
            else:
                # ti has lost focus
                if self.dropdown.selection_is_DLTI:
                    # do not dismiss if a DropListTextInput is the selection
                    return
    
                # dismiss DropDown
                self.dropdown.dismiss(ti)
                self.dropdown.unbind_all()
                self.dropdown.fbind('on_select', lambda self, x: setattr(ti, 'text', x))
    
    
    class MeasureDropDown(DropDown):
        # set to True if the selection is a DropListTextInput
        selection_is_DLTI = BooleanProperty(False)
    
        def unbind_all(self):
            for callBack in self.get_property_observers('on_select'):
                self.funbind('on_select', callBack)
    
    
    ####################################
    class AddWindow(Screen):
    
        def addIngredient(self, instance): #adds a new IngredientRow
            row = instance.parent
            row.remove_widget(row.children[0])
            row.add_widget(Factory.DelIngButton(), index=0)
            self.ingsGrid.add_widget(Factory.IngredientRow(), index=0)
    
    
    class WMan(ScreenManager):
        def __init__(self, **kwargs):
            super(WMan, self).__init__(**kwargs)
    
    # kv = Builder.load_file("ui/layout.kv")
    kv = Builder.load_string('''
    #:set text_color 0,0,0,.8
    
    #:set row_height '35sp'
    
    #:set main_padding ['10sp', '10sp']
    #:set small_padding ['5sp', '5sp']
    
    
    <DropListButton>: # Button for custom DropDown
        color: text_color
        background_normal: ''
    
    <DelIngButton>: # Button to delete row
        text: '-'
        size_hint: None, None
        height: row_height
        width: row_height
        on_release: self.parent.parent.remove_widget(self.parent)
    
    <MeasureDropDown>:
        id: dropDown
        DropListButton:
            size_hint: 1, None
            height: row_height
            text: "g"
        DropListButton:
            size_hint: 1, None
            height: row_height
            text: "Kg"
        DropListTextInput:  # CustomTextInput instead of standard TextInput
            dropDown: dropDown  # provide easy access to the DropDown
            size_hint: 1, None
            height: row_height
            hint_text: 'new'
            multiline: False  # needed to trigger on_text_validate
    
    <IngredientRow>:
        orientation: 'horizontal'
        size_hint: 1, None
        height: row_height
        spacing: '5sp'
        TextInput:
            id: ing
            hint_text: 'Ingredient'
            multiline: False
            size_hint: .6, None
            height: row_height
        TextInput:
            id: quant
            hint_text: 'Quantity'
            multiline: False
            size_hint: .2, None
            height: row_height
        TextInput:
            id: measure
            hint_text: 'measure'
            size_hint: .2, None
            height: row_height
            on_focus:
                root.handle_focus(self)  # focus event is now handled in the IngredientRow class
        Button:
            id: addIng
            text: "+"
            size_hint: None, None
            height: row_height
            width: row_height
            on_release: app.root.ids.add.addIngredient(self)
    
    
    <MainScrollView@ScrollView>:
        size_hint: 1, None
        scroll_type: ['bars', 'content']
    
    ##################
    # Windows
    ##################
    
    WMan:
        AddWindow:
            id: add
    
    <AddWindow>:
        name: 'add'
        ingsGrid: ingsGrid
        ingredientRow: ingredientRow
    
        MainScrollView:
            height: self.parent.size[1]
            GridLayout:
                cols:1
                size_hint: 1, None
                pos_hint: {"top": 1}
                height: self.minimum_height
                padding: main_padding
                StackLayout:
                    id: ingsGrid
                    size_hint: 1, None
                    height: self.minimum_height
                    orientation: 'lr-tb'
                    padding: small_padding
                    IngredientRow:
                        id: ingredientRow
    ''')
    
    
    class RecipApp(App):
        def build(self):
            return kv
    
    
    if __name__ == "__main__":
        RecipApp().run()
    

    I have added a DropListTextInput class for use in the MeasureDropDown and added a handle_focus() method to the IngredientRow class.

    I have also added a selection_is_DLTI BooleanProperty to the MeasureDropDown class which keeps track of whether the selected widget is a DropListTextInput.

    The new handle_focus() method does not dismiss the MeasureDropDown if the selected widget is a DropListTextInput.

    The DropListTextInput is limited to a single line, so that hitting Enter in it will trigger the on_text_validate() method, which sets the text in the measure TextInput and dismisses the MeasureDropDown.

    I used Builder.load_string() just for my own convenience.