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:
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)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)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)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)]
...
![]() |
![]() |
|---|---|
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:
- Click on the
Font FaceSetting - Hover over any font, and scroll-up
- Note that the font is erroneously "selected" (as if you clicked on it)
- Hover over any other font, and scroll to the left
- Note that the font is erroneously "selected" (as if you clicked on it)
- Hover over any other font, and scroll to the right
- 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"
![]() |
![]() |
|---|---|
| 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?




You can fix this by checking the
touch.buttonpassed to youron_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 widgetThe above
print()statements will iterate through every instance field in thetouchobject 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.buttoninstance field.In the case of the touch event, this was
In the case of the scroll event, this was
Solution
I was therefore able to fix the program by adding a
returnif thetouch.buttonis not the expectedleftmouse button