Quantcast
Channel: Active questions tagged python - Stack Overflow
Viewing all articles
Browse latest Browse all 13921

Scrolling causes click (on_touch_up) event on widgets in Kivy RecycleView

$
0
0

Why does scrolling call on_touch_up() in widgets in this Kivy RecycleView?

I created a custom SettingItem for use in Kivy's Settings Module. It's similar to the built-in Kivy SettingOptions, except it opens a new screen that lists all of options. This is more in-line with Material Design, and it allows for us to display a description about each option. I call it a ComplexOption.

Recently I had to create a ComplexOption that included thousands of options: a font picker. Displaying thousands of widgets in a ScrollView caused the app to crash, so I switched to a RecycleView. Now there is no performance degredation, but I did notice a strange effect:

The Problem

If a user scrolls "to the end", it registers the scroll event as a click event. This happens in all 4 directions:

  1. If a user is at the very "top" and they scroll up, then whatever widget that their cursor is over will register a click event, on_touch_up() will be called, and therefore my app will update the Config to the font under their cursor at thetime they were scrolling (as if they had clicked on the font)

  2. If a user scrolls "to the left", then whatever widget that their cursor is over will register a click event, on_touch_up() will be called, and therefore my app will update the Config to the font under their cursor at the time they were scrolling (as if they had clicked on the font)

  3. If a user scrolls "to the right", then whatever widget that their cursor is over will register a click event, on_touch_up() will be called, and therefore my app will update the Config to the font under their cursor at the time they were scrolling (as if they had clicked on the font)

  4. If a user is at the very "bottom" and they scroll down, then whatever widget that their cursor is over will register a click event, on_touch_up() will be called, and therefore my app will update the Config to the font under their cursor at the time they were scrolling (as if they had clicked on the font)

The code

I've tried my best to reduce the size of my app to a simple example of this behaviour for the purposes of this question. Consider the following files

Settings JSON

The following file named settings_buskill.json defines the Settings Panel

[    {"type": "complex-options","title": "Font Face","desc": "Choose the font in the app","section": "buskill","key": "gui_font_face","options": []    }]

Note that the options list gets filled at runtime with the list of fonts found on the system (see main.py below)

Kivy Language (Design)

The following file named buskill.kv defines the app layout

<-BusKillSettingItem>:    size_hint: .25, None    icon_label: icon_label    StackLayout:        pos: root.pos        orientation: 'lr-tb'        Label:            id: icon_label            markup: True            # mdicons doesn't have a "nbsp" icon, so we hardcode the icon to            # something unimportant and then set the alpha to 00 if no icon is            # defined for this SettingItem            #text: ('[font=mdicons][size=40sp][color=ffffff00]\ue256[/color][/size][/font]' if root.icon == None else '[font=mdicons][size=40sp]'+root.icon+'[/size][/font]')            text: 'A'            size_hint: None, None            height: labellayout.height        Label:            id: labellayout            markup: True            text: u'{0}\n[size=13sp][color=999999]{1}[/color][/size]'.format(root.title or '', root.value or '')            size: self.texture_size            size_hint: None, None            # set the minimum height of this item so that fat fingers don't have            # issues on small touchscreen displays (for better UX)            height: max(self.height, dp(50))<BusKillOptionItem>:    size_hint: .25, None    height: labellayout.height + dp(10)    radio_button_label: radio_button_label    StackLayout:        pos: root.pos        orientation: 'lr-tb'        Label:            id: radio_button_label            markup: True            #text: '[font=mdicons][size=18sp]\ue837[/size][/font] '            text: 'B'            size: self.texture_size            size_hint: None, None            height: labellayout.height        Label:            id: labellayout            markup: True            text: u'{0}\n[size=13sp][color=999999]{1}[/color][/size]'.format(root.value or '', root.desc or '')            font_size: '15sp'            size: self.texture_size            size_hint: None, None            # set the minimum height of this item so that fat fingers don't have            # issues on small touchscreen displays (for better UX)            height: max(self.height, dp(80))<ComplexOptionsScreen>:    color_main_bg: 0.188, 0.188, 0.188, 1    content: content    rv: rv    # sets the background from black to grey    canvas.before:        Color:            rgba: root.color_main_bg        Rectangle:            pos: self.pos            size: self.size    BoxLayout:        size: root.width, root.height        orientation: 'vertical'        RecycleView:            id: rv            viewclass: 'BusKillOptionItem'            container: content            bar_width: dp(10)            RecycleGridLayout:                default_size: None, dp(48)                default_size_hint: 1, None                size_hint_y: None                height: self.minimum_height                orientation: 'vertical'                id: content                cols: 1                size_hint_y: None                height: self.minimum_height<BusKillSettingsScreen>:    settings_content: settings_content    # sets the background from black to grey    canvas.before:        Rectangle:            pos: self.pos            size: self.size    BoxLayout:        size: root.width, root.height        orientation: 'vertical'        BoxLayout:            id: settings_content

main.py

The following file creates the Settings screen, populates the fonts, and displays the RecycleView when the user clicks on the Font Face setting

#!/usr/bin/env python3#################################################################################                                   IMPORTS                                    #################################################################################import os, operatorimport kivyfrom kivy.app import Appfrom kivy.core.text import LabelBasefrom kivy.core.window import WindowWindow.size = ( 300, 500 )from kivy.config import Configfrom kivy.uix.floatlayout import FloatLayoutfrom kivy.uix.screenmanager import ScreenManager, Screenfrom kivy.uix.settings import Settings, SettingSpacerfrom kivy.properties import ObjectProperty, StringProperty, ListProperty, BooleanProperty, NumericProperty, DictPropertyfrom kivy.uix.recycleview import RecycleView#################################################################################                                   CLASSES                                    ################################################################################## recursive function that checks a given object's parent up the tree until it# finds the screen manager, which it returnsdef get_screen_manager(obj):    if hasattr(obj, 'manager') and obj.manager != None:        return obj.manager    if hasattr(obj, 'parent') and obj.parent != None:        return get_screen_manager(obj.parent)    return None#################### SETTINGS SCREEN ##################### We heavily use (and expand on) the built-in Kivy Settings modules in BusKill# * https://kivy-fork.readthedocs.io/en/latest/api-kivy.uix.settings.html## Kivy's Settings module does the heavy lifting of populating the GUI Screen# with Settings and Options that are defined in a json file, and then -- when# the user changes the options for a setting -- writing those changes to a Kivy# Config object, which writes them to disk in a .ini file.## Note that a "Setting" is a key and an "Option" is a possible value for the# Setting.# # The json file tells the GUI what Settings and Options to display, but does not# store state. The user's chosen configuration of those settings is stored to# the Config .ini file.## See also https://github.com/BusKill/buskill-app/issues/16# We define our own BusKillOptionItem, which is an OptionItem that will be used# by the BusKillSettingComplexOptions class belowclass BusKillOptionItem(FloatLayout):    title = StringProperty('')    desc = StringProperty('')    value = StringProperty('')    parent_option = ObjectProperty()    manager = ObjectProperty()    def __init__(self, **kwargs):        super(BusKillOptionItem, self).__init__(**kwargs)    # this is called when the 'manager' Kivy Property changes, which will happen    # some short time after __init__() when RecycleView creates instances of    # this object    def on_manager(self, instance, value):        self.manager = value    def on_parent_option(self, instance, value):        if self.parent_option.value == self.value :            # this is the currenty-set option            # set the radio button icon to "selected"            self.radio_button_label.text = '[size=80sp][sup]\u2022[sup][/size][/font] '        else:            # this is not the currenty-set option            # set the radio button icon to "unselected"            self.radio_button_label.text = '[size=30sp][sub]\u006f[/sub][/size][/font] '    # this is called when the user clicks on this OptionItem (eg choosing a font)    def on_touch_up( self, touch ):        print( "called BusKillOptionItem().on_touch_up() !!" )        print( touch )        print( "\t" +str(dir(touch)) )        # skip this touch event if it wasn't *this* widget that was touched        # * https://kivy.org/doc/stable/guide/inputs.html#touch-event-basics        if not self.collide_point(*touch.pos):            return        # skip this touch event if they touched on an option that's already the        # enabled option        if self.parent_option.value == self.value:            msg = "DEBUG: Option already equals '" +str(self.value)+"'. Returning."            print( msg )            return        # enable the option that the user has clicked-on        self.enable_option()    # called when the user has chosen to change the setting to this option    def enable_option( self ):        # write change to disk in our persistant buskill .ini Config file        key = str(self.parent_option.key)        value = str(self.value)        msg = "DEBUG: User changed config of '" +str(key) +"' to '" +str(value)+"'"        print( msg );        Config.set('buskill', key, value)        Config.write()        # change the text of the option's value on the main Settings Screen        self.parent_option.value = self.value        # loop through every available option in the ComplexOption sub-Screen and        # change the icon of the radio button (selected vs unselected) as needed        for option in self.parent.children:            # is this the now-currently-set option?            if option.value == self.parent_option.value:                # this is the currenty-set option                # set the radio button icon to "selected"                option.radio_button_label.text = '[size=80sp][sup]\u2022[sup][/size][/font] '            else:                # this is not the currenty-set option                # set the radio button icon to "unselected"                option.radio_button_label.text = '[size=30sp][sub]\u006f[/sub][/size][/font] '# We define our own BusKillSettingItem, which is a SettingItem that will be used# by the BusKillSettingComplexOptions class below. Note that we don't have code# here because the difference between the SettingItem and our BusKillSettingItem# is what's defined in the buskill.kv file. that's to say, it's all visualclass BusKillSettingItem(kivy.uix.settings.SettingItem):    pass# Our BusKill app has this concept of a SettingItem that has "ComplexOptions"## The closeset built-in Kivy SettingsItem type is a SettingOptions#  * https://kivy-fork.readthedocs.io/en/latest/api-kivy.uix.settings.html#kivy.uix.settings.SettingOptions## SettingOptions just opens a simple modal that allows the user to choose one of# many different options for the setting. For many settings,# we wanted a whole new screen so that we could have more space to tell the user# what each setting does# Also, the whole "New Screen for an Option" is more# in-line with Material Design.#  * https://m1.material.io/patterns/settings.html#settings-usage## These are the reasons we create a special BusKillSettingComplexOptions classclass BusKillSettingComplexOptions(BusKillSettingItem):    # each of these properties directly cooresponds to the key in the json    # dictionary that's loaded with add_json_panel. the json file is what defines    # all of our settings that will be displayed on the Settings Screen    # options is a parallel array of short names for different options for this    # setting (eg 'lock-screen')    options = ListProperty([])    def on_panel(self, instance, value):        if value is None:            return        self.fbind('on_release', self._choose_settings_screen)    def _choose_settings_screen(self, instance):        manager = get_screen_manager(self)        # create a new screen just for choosing the value of this setting, and        # name this new screen "setting_<key>"         screen_name = 'setting_'+self.key        # did we already create this sub-screen?        if not manager.has_screen( screen_name ):            # there is no sub-screen for this Complex Option yet; create it            # create new screen for picking the value for this ComplexOption            setting_screen = ComplexOptionsScreen(             name = screen_name            )            # determine what fonts are available on this system            option_items = []            font_paths = set()            for fonts_dir_path in LabelBase.get_system_fonts_dir():                for root, dirs, files in os.walk(fonts_dir_path):                    for file in files[0:10]:                        if file.lower().endswith(".ttf"):                            font_path = str(os.path.join(root, file))                            font_paths.add( font_path )            print( "Found " +str(len(font_paths))+" font files." )            # create data for each font to push to RecycleView            for font_path in font_paths:                font_filename = os.path.basename( font_path )                option_items.append( {'title': 'title', 'value': font_filename, 'desc':'', 'parent_option': self, 'manager': manager } )            # sort list of fonts alphabetically and add to the RecycleView            option_items.sort(key=operator.itemgetter('value'))            setting_screen.rv.data.extend(option_items)            # add the new ComplexOption sub-screen to the Screen Manager            manager.add_widget( setting_screen )        # change into the sub-screen now        manager.current = screen_name# We define BusKillSettings (which extends the built-in kivy Settings) so that# we can add a new type of Setting = 'commplex-options'). The 'complex-options'# type becomes a new 'type' that can be defined in our settings json fileclass BusKillSettings(kivy.uix.settings.Settings):    def __init__(self, *args, **kargs):        super(BusKillSettings, self).__init__(*args, **kargs)        super(BusKillSettings, self).register_type('complex-options', BusKillSettingComplexOptions)# Kivy's SettingsWithNoMenu is their simpler settings widget that doesn't# include a navigation bar between differnt pages of settings. We extend that# type with BusKillSettingsWithNoMenu so that we can use our custom# BusKillSettings class (defined above) with our new 'complex-options' typeclass BusKillSettingsWithNoMenu(BusKillSettings):    def __init__(self, *args, **kwargs):        self.interface_cls = kivy.uix.settings.ContentPanel        super(BusKillSettingsWithNoMenu,self).__init__( *args, **kwargs )    def on_touch_down( self, touch ):        print( "touch_down() of BusKillSettingsWithNoMenu" )        super(BusKillSettingsWithNoMenu, self).on_touch_down( touch )# The ComplexOptionsScreen is a sub-screen to the Settings Screen. Kivy doesn't# have sub-screens for defining options, but that's what's expected in Material# Design. We needed more space, so we created ComplexOption-type Settings. And# this is the Screen where the user transitions-to to choose the options for a# ComplexOptionclass ComplexOptionsScreen(Screen):    pass# This is our main Screen when the user clicks "Settings" in the nav drawerclass BusKillSettingsScreen(Screen):    def on_pre_enter(self, *args):        # is the contents of 'settings_content' empty?        if self.settings_content.children == []:            # we haven't added the settings widget yet; add it now            # kivy's Settings module is designed to use many different kinds of            # "menus" (sidebars) for navigating different sections of the settings.            # while this is powerful, it conflicts with the Material Design spec,            # so we don't use it. Instead we use BusKillSettingsWithNoMenu, which            # inherets kivy's SettingsWithNoMenu and we add sub-screens for            # "ComplexOptions";             s = BusKillSettingsWithNoMenu()            s.root_app = self.root_app            # create a new Kivy SettingsPanel using Config (our buskill.ini config            # file) and a set of options to be drawn in the GUI as defined-by            # the 'settings_buskill.json' file            s.add_json_panel( 'buskill', Config, 'settings_buskill.json' )            # our BusKillSettingsWithNoMenu object's first child is an "interface"            # the add_json_panel() call above auto-pouplated that interface with            # a bunch of "ComplexOptions". Let's add those to the screen's contents            self.settings_content.add_widget( s )class BusKillApp(App):    # copied mostly from 'site-packages/kivy/app.py'    def __init__(self, **kwargs):        super(App, self).__init__(**kwargs)        self.built = False    # instantiate our scren manager instance so it can be accessed by other    # objects for changing the kivy screen    manager = ScreenManager()    def build_config(self, config):        Config.read( 'buskill.ini' )        Config.setdefaults('buskill', {'gui_font_face': None,        })          Config.write()    def build(self):        screen = BusKillSettingsScreen(name='settings')        screen.root_app = self        self.manager.add_widget( screen )        return self.manager#################################################################################                                  MAIN BODY                                   #################################################################################if __name__ == '__main__':    BusKillApp().run()

To Reproduce

To reproduce the issue, create all three of the above files in the same directory on a system with python3 and python3-kivy installed

user@host:~$ lsbuskill.kv  main.py  settings_buskill.jsonuser@host:~$ 

Then execute python3 main.py

user@buskill:~/tmp/rv/src$ python3 main.py [INFO   ] [Logger      ] Record log in /home/user/.kivy/logs/kivy_24-03-18_55.txt[INFO   ] [Kivy        ] v1.11.1[INFO   ] [Kivy        ] Installed at "/tmp/kivy_appdir/opt/python3.7/lib/python3.7/site-packages/kivy/__init__.py"[INFO   ] [Python      ] v3.7.8 (default, Jul  4 2020, 10:00:57) [GCC 9.3.1 20200408 (Red Hat 9.3.1-2)]...
Screenshot of a simple kivy app displaying a clickable button with the text "Font Face"Screenshot of a simple kivy app showing a list of font files on a scrollable screen
Click on the Font Face Setting to change screens to the list of fonts to choose-fromScrolling "left" over the Arimo-Italic.ttf font label will erroneously "click" it

In the app that opens:

  1. Click on the Font Face Setting
  2. Hover over any font, and scroll-up
  3. Note that the font is erroneously "selected" (as if you clicked on it)
  4. Hover over any other font, and scroll to the left
  5. Note that the font is erroneously "selected" (as if you clicked on it)
  6. Hover over any other font, and scroll to the right
  7. Note that the font is erroneously "selected" (as if you clicked on it)

Note For simplicity, I've replaced the Material Design Icons used to display checked & unchecked radio box icons with simple unicode in the built-in (Roboto) font.

So the hollow circle is a crude "unchecked radio box" and the filled-in circle is a crude "checked radio box"

Whey does the above app call on_touch_up() when a user scrolls over a widget in the RecycleView?


Viewing all articles
Browse latest Browse all 13921

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>