滚动导致Kivy RecycleView中的小部件触发点击事件

0 投票
1 回答
23 浏览
提问于 2025-04-13 19:51

为什么在这个 Kivy 的 RecycleView 中,滚动会调用 on_touch_up() 方法?

我创建了一个自定义的 SettingItem,用于 Kivy 的 设置模块。它和 Kivy 内置的 SettingOptions 类似,但它会打开一个新屏幕,列出所有选项。这更符合 材料设计,并且可以让我们显示每个选项的描述。我把它叫做 ComplexOption

最近,我需要创建一个包含成千上万选项的 ComplexOption:一个字体选择器。在 ScrollView 中显示成千上万的组件导致应用崩溃,所以我换成了 RecycleView。现在性能没有下降,但我注意到了一个奇怪的现象:

问题

如果用户滚动到“尽头”,它会把滚动事件当作点击事件来处理。这种情况在四个方向上都会发生:

  1. 如果用户在最“顶部”,向上滚动,那么光标所在的组件会注册一个点击事件,on_touch_up() 会被调用,因此我的应用会把配置更新为光标下的字体,就好像他们点击了这个字体一样。

  2. 如果用户向“左”滚动,那么光标所在的组件会注册一个点击事件,on_touch_up() 会被调用,因此我的应用会把配置更新为光标下的字体,就好像他们点击了这个字体一样。

  3. 如果用户向“右”滚动,那么光标所在的组件会注册一个点击事件,on_touch_up() 会被调用,因此我的应用会把配置更新为光标下的字体,就好像他们点击了这个字体一样。

  4. 如果用户在最“底部”,向下滚动,那么光标所在的组件会注册一个点击事件,on_touch_up() 会被调用,因此我的应用会把配置更新为光标下的字体,就好像他们点击了这个字体一样。

代码

为了这个问题,我尽量把我的应用简化成一个简单的示例。请看以下文件:

设置 JSON

以下名为 settings_buskill.json 的文件定义了设置面板。

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

注意,options 列表在运行时会填充系统中找到的字体列表(见下面的 main.py)。

Kivy 语言(设计)

以下名为 buskill.kv 的文件定义了应用布局。

<-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

以下文件创建了设置屏幕,填充字体,并在用户点击 Font Face 设置时显示 RecycleView。

#!/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()

重现步骤

要重现这个问题,请在安装了 python3 和 python3-kivy 的系统中,在同一目录下创建上述三个文件。

user@host:~$ ls
buskill.kv  main.py  settings_buskill.json
user@host:~$ 

然后执行 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)]
...
简单 Kivy 应用的截图,显示一个可点击的按钮,上面写着 "Font Face" 简单 Kivy 应用的截图,显示一个可滚动屏幕上的字体文件列表
点击 Font Face 设置,切换到字体选择列表 向 "左" 滚动 Arimo-Italic.ttf 字体标签会错误地 "点击" 它

在打开的应用中:

  1. 点击 Font Face 设置
  2. 将光标悬停在任何字体上,然后向上滚动
  3. 注意字体被错误地 "选中"(就像你点击了它一样)
  4. 将光标悬停在其他字体上,然后向左滚动
  5. 注意字体被错误地 "选中"(就像你点击了它一样)
  6. 将光标悬停在其他字体上,然后向右滚动
  7. 注意字体被错误地 "选中"(就像你点击了它一样)

注意 为了简单起见,我用简单的 Unicode 替换了用于显示选中和未选中单选框图标的材料设计图标,使用的是内置的(Roboto)字体。

所以空心圆是一个粗略的 "未选中单选框",填充圆是一个粗略的 "选中单选框"

看起来像遵循材料设计规范的 Android 应用的 Kivy 应用截图 显示许多字体的 Kivy 应用截图,包含每个字体旁边的正确材料设计单选按钮
原始应用包含来自材料设计字体的图标 原始应用包含来自材料设计字体的图标

为什么上面的应用在用户滚动到 RecycleView 中的组件时会调用 on_touch_up()

1 个回答

0

你可以通过检查传递给你的 on_touch_up() 函数的 touch.button 来解决这个问题。

我不太清楚为什么滚动事件会被当作点击事件处理,但我在我的 on_touch_up() 函数中添加了一些调试输出,想看看点击/轻触小部件和在小部件上滚动时是否有任何区别。

   def on_touch_up( self, touch ):

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

上面的 print() 语句会遍历 touch 对象中的每个实例字段,并输出它的值。

然后我把点击小部件时的输出 [a] 和仅在小部件上滚动时的输出 [b] 复制了过来,并将这两个片段粘贴到一个可视化比较工具中(例如 meld)。

最重要的属性是 touch.button 实例字段。

在触摸事件的情况下,这个值是

('button', 'left')

在滚动事件的情况下,这个值是

('button', 'scrollleft')

解决方案

因此,我通过添加一个 return 语句来修复程序,条件是 touch.button 不是预期的 left 鼠标按钮。

    # 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

撰写回答