Kivy - 文本换行不正确

2 投票
2 回答
1904 浏览
提问于 2025-04-18 07:18

我在尝试在Kivy(1.8.0)应用中让文本自动换行。
当文本不多的时候,一切都很好。
但是如果文本很长,而窗口又不大的话,文本就会被截断。

这是一个示例代码:

vbox = BoxLayout(orientation="vertical", size_hint_y=None)
text = Label(text="Some very long text")
text.bind(size=text.setter("text_size"))
title = Label(text="Some very long title")
title.bind(size=title.setter("text_size"))
vbox.add_widget(title)
vbox.add_widget(text)

在移动设备上,这种情况简直让人受不了。

截图:

全屏

全屏

小窗口

小窗口

有没有办法解决这个问题?

2 个回答

1

我自己也遇到过这个问题,查看了关于文本输入的源代码,发现有几个函数负责处理换行,主要是_split_smart_refresh_hint_text。然后我又看了文档,发现了一些提示,帮助我理解发生了什么...

当你改变一个需要重新绘制的TextInput属性,比如修改文本时,更新会在下一个时钟周期进行,而不是立刻发生...

... 他们并没有直接说出来,但实际上这些小部件会被赋予一些默认的高度和宽度限制(我测试的文本输入的默认宽度是width: 50),然后如果有文本的话,会在某个时刻通过_smart_split来处理换行,但之后就不会再更新了...

如果你不在意父级小部件的高度也被更新的话,简单的解决方案是这样的。

class cTextInput(TextInput):
    def on_width(self, instance, value):
        self.text = self.text

而更复杂和完整的解决方案则会同时更新高度和其他一些设置。

#!/usr/bin/env python

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.label import Label
from kivy.uix.textinput import TextInput
from kivy.uix.gridlayout import GridLayout
from kivy.clock import Clock

from collections import OrderedDict

kv_string = """
#:set rgb_red [1, 0, 0, 0.25]
#:set rgb_purple [0.98, 0.06, 1.0, 0.5]
#:set rgb_green [0.05, 0.47, 0.35, 0.5]
#:set rgb_cyan [0.43, 0.87, 0.81, 0.5]
#:set rgb_blue [0.14, 0.09, 0.76, 0.5]
#:set rgb_salmon [0.98, 0.47, 0.35, 0.5]
#:set rgb_clear [0, 0, 0, 0]

Main_GridLayout: ## Defined root so Builder.load_string returns something useful

<Main_GridLayout>:
    cols: 1
    rows: 2
    spacing: 0
    row_force_default: True
    rows_minimum: {0: action_bar.height, 1: self.height - action_bar.height}
    Custom_ActionBar: ## IDs for scope of this widget seem to allow for auto sizing to available space
        id: action_bar
    ScrollView:
        id: scroller


<Custom_ActionBar@ActionBar>: ## Place holder to show that scrolling is within a target widget.
    ActionView:
        ActionPrevious: ## Hidden without side effects
            with_previous: False
            app_icon: ''
            app_icon_width: 0
            app_icon_height: 0
            title: ''
        ActionGroup: ## Text changes with user selection
            id: foobar_action_group
            mode: 'spinner'
            text: 'Foo'
            ActionButton:
                text: "Foo"
                on_press: foobar_action_group.text = self.text
            ActionButton:
                text: "Bar"
                on_press: foobar_action_group.text = self.text


<Adaptive_GridLayout>: ## Going to have to read the Python methods for all of the fanciness this widget has.
    spacing: 10
    row_force_default: True
    size_hint_y: None
    rows_minimum: self.calc_rows_minimum()
    height: self.calc_min_height()

    default_background_color: rgb_green
    selected_background_color: rgb_cyan
    background_color: 0.05, 0.47, 0.35, 0.5
    # background_color: rgb_green ## TypeError: 'NoneType' object is not iterable -- Line 262 of context_instructions.py
    canvas.before:
        Color:
            rgba: self.background_color
        Rectangle:
            pos: self.pos
            size: self.size


<Row_GridLayout>: ## Inherits from Adaptive_GridLayout
    rows: 1
    spacing: 0
    default_background_color: rgb_blue
    selected_background_color: rgb_salmon
    background_color: rgb_blue ## But this is okay?

    Row_Label:
        id: label


<Row_Label@Label>: ## Example of kv only widget that does stuff
    size_hint_y: None
    height: self.texture_size[1]

    selected: False

    default_background_color: rgb_clear
    selected_background_color: rgb_red
    background_color: 0, 0, 0, 0
    canvas.before:
        Color:
            rgba: self.background_color
        Rectangle:
            pos: self.pos
            size: self.size

    on_touch_down:
        caller = args[0]
        touch = args[1]

        touched = caller.collide_point(*touch.pos)

        if touched:\
        caller.selected = caller.background_color == caller.default_background_color;\
        print('{0}.selected -> {1}'.format(caller, caller.selected))

        if touched and caller.selected: caller.background_color = self.selected_background_color
        elif touched and not caller.selected: caller.background_color = caller.default_background_color


<Adaptive_TextInput>:
    synopsis_line_limit: 2
    use_bubble: True
    multiline: True
    # readonly: True
    allow_copy: True
    # text: ('Foobarred' * 10) * 40
    text: ''
    size_hint_y: None
    height: self.minimum_height

"""


class Adaptive_TextInput(TextInput):
    def __init__(self, synopsis_line_limit = None, **kwargs):
        self.old_width = self.width
        self.old_height = self.height
        self.synopsis_line_limit = synopsis_line_limit
        self.synopsis_text = ''
        self.full_text = ''

        self.my_hero = super(Adaptive_TextInput, self)
        self.my_hero.__init__(**kwargs)

    def refresh_overflow_values(self, text):
        """ Uses '_split_smart' and '_get_text_width' methods from TextInput to generate synopsis text. """
        self.full_text = text

        lines, lines_flags = self._split_smart(text)
        if self.synopsis_line_limit is None:
            synopsis_line_limit = len(lines)
        else:
            synopsis_line_limit = self.synopsis_line_limit

        if len(lines) > synopsis_line_limit:
            synopsis_lines = lines[:synopsis_line_limit]
            synopsis_line = ''.join(synopsis_lines)
            available_width = self.width - self.padding[0] - self.padding[2]
            text_width = self._get_text_width(synopsis_line, self.tab_width, self._label_cached)
            if (text_width + 3) > available_width:
                self.synopsis_text = '{0}...'.format(synopsis_line[:-3])
            else:
                self.synopsis_text = synopsis_line
        else:
            self.synopsis_text = text

    def refresh_text_value(self):
        """ Sets 'self.text' to either 'self.full_text' or 'self.synopsis_text' based off 'self.focused' value. """
        if self.focused is True:
            self.text = self.full_text
        else:
            self.text = self.synopsis_text
        self._trigger_update_graphics() ## Does not seem to be needed but tis what refreshing of 'hint_text' method does.

    def propagate_height_updates(self):
        """ Update grid layouts to height changes. """
        containing_grids = [g for g in self.walk_reverse() if hasattr(g, 'refresh_grids_y_dimension')]
        for grid in containing_grids:
            grid.refresh_grids_y_dimension()

    def on_focused(self, instance, value):
        """ Sets 'self.focused' value and triggers updates to methods that are interested in such values. """
        self.focused = value
        self.refresh_text_value()
        self.propagate_height_updates()

    def on_size(self, instance, value):
        """ This is the magic that fires updates for line wrapping when widget obtains a new width size as well as
            updating grid layouts and their progenitors on new heights.
        """
        self.my_hero.on_size(instance, value)
        if self.old_width is not self.width: ## Only bother if width has changed
            self.old_width = self.width
            self.refresh_overflow_values(text = self.full_text)
            self.refresh_text_value()
        if self.old_height is not self.height:
            self.old_height = self.height
            self.propagate_height_updates()

    def on_text(self, instance, text):
        """ Updates text values via 'self.refresh_overflow_values(text = value)' only if focused. """
        if self.focused is True:
            self.refresh_overflow_values(text = text)

    def on_parent(self, instance, value):
        """ Wait for parenting to set customized text values because 'self.text' maybe set after initialization. """
        self.refresh_overflow_values(text = self.text)
        self.refresh_text_value()


class Adaptive_GridLayout(GridLayout):
    """ Adaptive height and row heights for grid layouts. """
    def __init__(self, **kwargs):
        self.my_hero = super(Adaptive_GridLayout, self)
        self.my_hero.__init__(**kwargs)

    def yield_tallest_of_each_row(self):
        """ Yields tallest child of each row within gridlayout. """
        current_tallest = None
        for i, c in enumerate(list(reversed(self.children))):
            if current_tallest is None:
                current_tallest = c

            if c.height > current_tallest.height:
                current_tallest = c

            if self.cols is None or self.cols is 0: ## Should work around grids without value for 'cols'
                yield current_tallest
                current_tallest = None
            elif ((i + 1) % self.cols == 0) is True: ## Reached last item of current row.
                yield current_tallest
                current_tallest = None

    def calc_child_padding_y(self, child):
        """ Returns total padding for a given child. """
        try: ## Likely faster than asking permission with an if statement as most widgets seem to have padding
            child_padding = child.padding
        except AttributeError as e:
            child_padding = [0]

        len_child_padding = len(child_padding)
        if len_child_padding is 1:
            padding = child_padding[0] * 2
        elif len_child_padding is 2:
            padding = child_padding[1] * 2
        elif len_child_padding > 2:
            padding = child_padding[1] + child_padding[3]
        else:
            padding = 0

        return padding

    def calc_min_height(self):
        """ Returns total height required to display tallest children of each row plus spacing between widgets. """
        min_height = 0
        for c in self.yield_tallest_of_each_row():
            c_height = c.height + self.calc_child_padding_y(child = c)
            min_height += c_height + self.spacing[1]
        return min_height

    def calc_rows_minimum(self):
        """ Returns ordered dictionary of how high each row should be to accommodate tallest children of each row. """
        rows_minimum = OrderedDict()
        for i, c in enumerate(self.yield_tallest_of_each_row()):
            c_height = c.height + self.calc_child_padding_y(child = c)
            rows_minimum.update({i: c_height})
        return rows_minimum

    def refresh_height(self):
        """ Resets 'self.height' using value returned by 'calc_min_height' method. """
        self.height = self.calc_min_height()

    def refresh_rows_minimum(self):
        """ Resets 'self.rows_minimum' using value returned by 'calc_rows_minimum' method. """
        self.rows_minimum = self.calc_rows_minimum()

    def refresh_grids_y_dimension(self):
        """ Updates 'height' and 'rows_minimum' first for spawn, then for self, and finally for any progenitors. """
        grid_spawn = [x for x in self.walk(restrict = True) if hasattr(x, 'refresh_grids_y_dimension') and x is not self]
        for spawn in grid_spawn:
            spawn.refresh_rows_minimum()
            spawn.refresh_height()

        self.refresh_rows_minimum()
        self.refresh_height()

        grid_progenitors = [x for x in self.walk_reverse() if hasattr(x, 'refresh_grids_y_dimension') and x is not self]
        for progenitor in grid_progenitors:
            progenitor.refresh_rows_minimum()
            progenitor.refresh_height()

    def on_parent(self, instance, value):
        """ Some adjustments maybe needed to get top row behaving on all platforms. """
        Clock.schedule_once(lambda _ : self.refresh_grids_y_dimension(), 0.461251)

    def on_touch_down(self, touch):
        """ Place to throw debugging lines for test interactions as this should be removed before release. """
        touched = self.collide_point(*touch.pos)
        spawn_touched = [x.collide_point(*touch.pos) for x in self.walk(restrict = True) if x is not self]
        if touched is True and True not in spawn_touched: ## Example of how to fire on lonely touches...
            if self.background_color == self.default_background_color:
                self.background_color = self.selected_background_color
            else:
                self.background_color = self.default_background_color

            print('{0}.height -> {1}'.format(self, self.height))
            for c in self.children:
                print('\t{0}.height -> {1}'.format(c, c.height))

        else: ## ... and return to defaults if others where touched.
            self.background_color = self.default_background_color

        ## Supering allows spawn to also register touch events
        self.my_hero.on_touch_down(touch)


class Row_GridLayout(Adaptive_GridLayout):
    """ Magic is inherited from Adaptive_GridLayout, mostly... """
    def on_parent(self, instance, value):
        """ Overwriting inherited on_parent method to avoid over calling Clock. """
        pass


class Main_GridLayout(GridLayout):
    """ Check 'kv_string' for layout widgets. """
    pass


class SomeApp(App):
    """ SomeApp flaunts it because it has gots it. """
    def build(self):
        root_layout = Builder.load_string(kv_string)

        container = Adaptive_GridLayout(cols = 1)
        for x in range(0, 5):
            # row = Row_GridLayout(rows = 1) ## IndexError: list index out of range -- Line 324 of gridlayout.py
            row = Row_GridLayout(cols = 2)
            row.ids.label.text = 'Row #{0}'.format(x)

            ## Growing amount of junk text quickly on each iteration for swifter demonstration
            text = 'Foobarred' * ((x + 1) * (x + 2))
            textinput = Adaptive_TextInput()
            textinput.text = text

            row.add_widget(textinput)
            container.add_widget(row)

        root_layout.ids.scroller.add_widget(container)

        return root_layout


if __name__ == '__main__':
    SomeApp().run()
1

text_size 必须和窗口的大小一样,所以可以试试这个代码:text.bind(text_size=text.setter("size"))

这里有一个例子,来自于 https://kivy.org/planet/2014/07/wrapping-text-in-kivy,不过是用 kv 语言写的,而不是用 Python。

撰写回答