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

26 Views Asked by At

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 the time 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, operator

import kivy
from kivy.app import App
from kivy.core.text import LabelBase
from kivy.core.window import Window
Window.size = ( 300, 500 )

from kivy.config import Config

from kivy.uix.floatlayout import FloatLayout
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.uix.settings import Settings, SettingSpacer
from kivy.properties import ObjectProperty, StringProperty, ListProperty, BooleanProperty, NumericProperty, DictProperty
from 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 returns
def 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 below
class 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 visual
class 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 class
class 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 file
class 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' type
class 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
# ComplexOption
class ComplexOptionsScreen(Screen):
    pass

# This is our main Screen when the user clicks "Settings" in the nav drawer
class 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:~$ ls
buskill.kv  main.py  settings_buskill.json
user@host:~$ 

Then execute python3 main.py

user@host:~$ 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-from Scrolling "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"

Screenshot of a Kivy app that looks lieke an Android app following the Material Design Spec Screenshot of a Kivy app with many fonts listed on a scrollable screen, including proper material design radio buttons next to each font
The original app includes icons from the Material Design Font The original app includes icons from the Material Design Font

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

1

There are 1 best solutions below

0
Michael Altfield On

You can fix this by checking the touch.button passed to your on_touch_up() function.

I don't know exactly why scroll events are being passed as click events, but I added some debugging output to my on_touch_up() function to see if I could detect any differences between when I click/tap on the widget vs when I scroll over the widget

   def on_touch_up( self, touch ):

      print( "DEBUG: Incoming touch" )
      print( "touch.__dict__.items():|" +str(touch.__dict__.items())+ "|" )

The above print() statements will iterate through every instance field in the touch object and output its value.

I then copied the output from [a] when I clicked on a widget and [b] when I just scrolled over the top of a widget. And I pasted these two snippets into a visual differ (eg meld).

The most important attribute stood-out was the touch.button instance field.

In the case of the touch event, this was

('button', 'left')

In the case of the scroll event, this was

('button', 'scrollleft')

Solution

I was therefore able to fix the program by adding a return if the touch.button is not the expected left mouse button

    # this is called when the user clicks on this OptionItem (eg choosing a font)
    def on_touch_up( self, 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 it was actually a scroll event
        # * https://stackoverflow.com/questions/78183125/scrolling-causes-click-on-touch-up-event-on-widgets-in-kivy-recycleview
        if touch.button != "left":
            return