#9378·kivy

RecycleView: resize along non-layout axis breaks index widget mapping and restarts view state

Author: mushket-re0ahCreated Sep 17, 2026Updated Sep 18, 2026
LabelsType: BugPriority: Low

Software Versions

  • Python: v3.14.6 (main, Jun 15 2026, 11:36:54) [GCC 16.1.1 20260430]
  • OS: arch linux desktop 7.1.2-arch3-1 #1 SMP PREEMPT_DYNAMIC Fri, 03 Jul 2026 23:25:36 +0000 x86_64 GNU/Linux
  • Kivy: 2.3.1
  • Kivy installation method: pip

also checked on

  • Python: v3.8.7 (tags/v3.8.7:6503f05, Dec 21 2020, 17:59:51) [MSC v.1928 64 bit (AMD64)]
  • OS: windows 7
  • Kivy: 2.3.1
  • Kivy installation method: pip

Not checked on master.

Describe the bug When a RecycleView is resized along the axis which not used for layout (width for RecycleBoxLayout(orientation="vertical") or height for horizontal`), the correspondence between indices and widget instances is broken. Visible rows are reversed on every other layout pass again and again.

The issue is invisible for viewclasses without inner state (because data repeatly used through refresh_view_attrs), but, for example, Animation, Clock triggers, focus, pressed, cached attributes and etc not reset by refresh_view_attrs. In practice this shows like animation restarting on every resize tick and as visual state jumping to neighboring items.

Vertical case:

kv
RecycleView:
    viewclass: "RowButton"
    RecycleBoxLayout:
        orientation: "vertical"
        size_hint: (1, None)
        size_hint_y: None
        height: self.minimum_height
        default_size: (None, "30dp")
        default_size_hint: (1, None)

Horizontal case:

kv
RecycleView:
    viewclass: "RowButton"
    RecycleBoxLayout:
        orientation: "horizontal"
        size_hint: (None, 1)
        size_hint_x: None
        width: self.minimum_width
        default_size: ("60dp", None)
        default_size_hint: (None, 1)

In both cases, whether it is a viewclass triggering an animation on itself when a data field change state, or a parent that resize the RecycleView along the non-layout axis, the animation is restarted on every tick even nothing in the layout changed.

Root cause. Method RecycleLayout._catch_layout_trigger compares instance.size == opt['size'] by both axes in a single expression.

python
# kivy/uix/recyclelayout.py, row 218
def _catch_layout_trigger(self, instance=None, value=None):
    rv = self.recycleview
    if rv is None:
        return

    idx = self.view_indices.get(instance)
    if idx is not None:
        if self._size_needs_update:
            return
        opt = self.view_opts[idx]
        if (instance.size == opt['size'] and    # <-- both axes in time
                instance.size_hint == opt['size_hint'] and
                instance.size_hint_min == opt['size_hint_min'] and
                instance.size_hint_max == opt['size_hint_max'] and
                instance.pos_hint == opt['pos_hint']):
            return
        self._size_needs_update = True        # <-- falsy trigger
        rv.refresh_from_layout(view_size=True)
    else:
        rv.refresh_from_layout()

But value opt['size'][i] have meaningful only for axis with fixed size (where size_hint[i] is None). For the axis controlled by size_hint, the component keeps whatever compute_sizes_from_data wrote there: initial_width (100 by default) if the size component is None in the data, or the explicit size[i] value if it was provided. compute_layout updates only the component of the axis that is fixed (method of RecycleLayout.compute_layout):

python
# kivy/uix/recyclelayout.py, row 327
if shnw is None:
    if shnh is None:
        opt['size'] = sn
    else:
        opt['size'] = [w, s[1]]
elif shnh is None:
    opt['size'] = [s[0], h]   # only 'h' is refreshed, 's[0]' is stale

So on a vertical RecycleBoxLayout with default_size_hint: (1, None), opt['size'][0] stays at the initial placeholder forever while widget.size[0] follows the container width. The equality check therefore fails on every width change (method of RecycleLayout.compute_sizes_from_data).

python
# kivy/uix/recyclelayout.py, row 239
iw, ih = self.initial_size
...
opts[i] = {
    'size': [(iw if w is None else w), (ih if h is None else h)],   # <-- placeholder
    'size_hint': sh, 'size_hint_min': sh_min,
    'size_hint_max': sh_max, 'pos': None, 'pos_hint': ph,
    'viewclass': viewclass, 'width_none': w is None,
    'height_none': h is None}

If non-layout axis change the size instance.size[i] changes, but opt['size'][i] stays at the placeholder, the equality fails, and _size_needs_update is set to True. The same comparison error exists in the changed block (same axis-blind comparison; not quoted here to avoid duplication), at the end of RecycleLayout.set_visible_views.

size_hint_min and size_hint_max are compared in the same axis-blind way. They are None in the reproduction and do not contribute here, but any axis-aware fix should handle them consistently.

Downstream effect. The set of _size_needs_update = True causes RecycleBoxLayout.compute_layout to call clear_layout() (method of RecycleLayoutManagerBehavior)

python
# kivy/uix/recycleview/layout.py, row 237
def clear_layout(self):
    ...
    adapter = self.recycleview.view_adapter
    if adapter:
        adapter.invalidate()

which called adapter.invalidate() (Method of RecycleDataAdapter).

python
# kivy/uix/recycleview/views.py, row 359
def invalidate(self):
    for view in self.views.values():     # <-- order is [A,B,C,D,E]
        _cached_views[view.__class__].append(view)
    ...
    self.views = {}
    self.dirty_views.clear()

Traceback captured from a resize tick from refresh_views to invalidate: You can patch the RecycleDataAdapter.invalidate method:

python
# kivy/uix/recycleview/views.py, row 359
def invalidate(self):
    import traceback
    traceback.print_stack()
    ...

As result you will see the call stack:

python
File "/usr/lib/python3.14/site-packages/kivy/clock.py", line 783, in tick_draw
  self._process_events_before_frame()
File "/usr/lib/python3.14/site-packages/kivy/uix/recycleview/__init__.py", line 333, in refresh_views
  lm.compute_layout(data, f)
File "/usr/lib/python3.14/site-packages/kivy/uix/recycleboxlayout.py", line 104, in compute_layout
  self.clear_layout()
File "/usr/lib/python3.14/site-packages/kivy/uix/recyclelayout.py", line 447, in clear_layout
  super(RecycleLayout, self).clear_layout()
File "/usr/lib/python3.14/site-packages/kivy/uix/recycleview/layout.py", line 242, in clear_layout
  adapter.invalidate()
File "/usr/lib/python3.14/site-packages/kivy/uix/recycleview/views.py", line 371, in invalidate
  traceback.print_stack()

invalidate() moves all currently visible views into the module-level _cached_views list in insertion order. On the next set_visible_views method RecycleDataAdapter.get_view pulls them back with list.pop(). It is LIFO.

python
# kivy/uix/recycleview/views.py, row 229
def get_view(self, index, data_item, viewclass):
    ...
    elif _cached_views[viewclass]:
        view, stale = _cached_views[viewclass].pop(), True   # <-- LIFO → [E,D,C,B,A]
        ...

Adapter output for two consecutive resize ticks. You can patch the RecycleLayout.set_visible_views method:

python
# kivy/uix/recycleview/views.py, row 368
def set_visible_views(self, indices, data, viewport):
    ...
    new, remaining, old = self.recycleview.view_adapter.set_visible_views(
        indices, data, view_opts)
    print(f"indices={indices} new={[(i, w._uid) for i, w in new]} "
          f"remaining={[(i, w._uid) for i, w in remaining]}")
    ...

As result you will see reversing:

python
...
indices=[0, 1, 2, 3, 4, 5, 6, 7] new=[(0, 2), (1, 1), (2, 0), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7)] remaining=[]
indices=[0, 1, 2, 3, 4, 5, 6, 7] new=[(0, 7), (1, 6), (2, 5), (3, 4), (4, 3), (5, 0), (6, 1), (7, 2)] remaining=[]
indices=[0, 1, 2, 3, 4, 5, 6, 7] new=[(0, 2), (1, 1), (2, 0), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7)] remaining=[]
indices=[0, 1, 2, 3, 4, 5, 6, 7] new=[(0, 7), (1, 6), (2, 5), (3, 4), (4, 3), (5, 0), (6, 1), (7, 2)] remaining=[]
indices=[0, 1, 2, 3, 4, 5, 6, 7] new=[(0, 2), (1, 1), (2, 0), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7)] remaining=[]
indices=[0, 1, 2, 3, 4, 5, 6, 7] new=[(0, 7), (1, 6), (2, 5), (3, 4), (4, 3), (5, 0), (6, 1), (7, 2)] remaining=[]
...

So, order is reversed. The next layout pass reverses it again. Because value of opt['size'][i] for axis, which controlled by size_hint is never updated in the process, so the false positive repeats on every frame of the resize.

Thats two defects are independent but compound: the comparison in recyclelayout.py is a trigger, and the LIFO turns a spurious re-layout into a visible widget/index permutation.

Expected behavior For a RecycleBoxLayout with orientation: "vertical", changing the width of the RecycleView should not trigger any relayout of the RecycleBoxLayout. Positions and heights of rows are unaffected by width, and minimum_height does not depend on it. The only thing that should change is the width of each visible row, it should follow the container via size_hint_x: 1, exactly as a normal Layout child would. Symmetrically, for orientation: "horizontal", changing the height should not trigger relayout.

Specifically, for a vertical layout with rows set to size_hint_x: 1, changing the container width should: The minimal invariant of the trigger logic:

  1. Not set the RecycleLayout._size_needs_update flag.
  2. Not call clear_layout() or adapter.invalidate() methods. The outcome the fix must eventually achieve:
  3. Not move any row to a different index, the mapping index widget instance must be preserved accross the resize
  4. Keep state of any widget that is not reset by refresh_view_attrs (running Animation, Clock triggers, focus, pressed, cached attributes, etc) attached to the same row.

Achieving points 1–2 (no spurious re-layout) without also updating the width of the views in the remaining list is not sufficient, currently refresh_view_layout is only invoked for views in new, so the width update of reused views is a side effect of the very invalidate call we want to remove. A complete fix must address both.

In the reproduce, the _uid value for a specific index must remain constant through the resizing process, and the active row's animation must not restart when the container's width changes.

To Reproduce With that reproduce example you will see, how widgets is reversed by LIFO and how changing of size_hint_x (which should be unused for comparison and should not trigger an update) trigger that.

What to observe. Each row renders idx={index} _uid={self._uid}. After each resize tick, _uid for a given idx changes: the row that was at idx=0 becomes the row that was at idx=4, and vice versa on the next tick. Without the text label, the permutation would be invisible, the same data is re-applied to each widget via refresh_view_attrs, so the underlying bug is masked. The animation on active makes it visually obvious.

It is vertical case.

python
from itertools import count
from kivy.app import App
from kivy.core.window import Window
from kivy.lang import Builder
from kivy.clock import Clock
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.button import Button
from kivy.properties import BooleanProperty, NumericProperty, ObjectProperty
from kivy.animation import Animation


Builder.load_string("""
<RowButton>:
    background_color: (0.15 + 0.85 * self.pulse, 0.15, 0.15, 1)

<Root>:
    recycle_view: recycle_view
    RecycleView:
        id: recycle_view
        size_hint: (0.5, 0.5)
        pos_hint: {"x": 0.25, "center_y": 0.5}
        viewclass: "RowButton"
        RecycleBoxLayout:
            orientation: "vertical"
            size_hint: (1, None)
            size_hint_y: None
            height: self.minimum_height
            default_size: (None, "30dp")
            default_size_hint: (1, None)
            spacing: "4dp"
""")


class RowButton(RecycleDataViewBehavior, Button):
    index = NumericProperty(-1)
    active = BooleanProperty(False)
    pulse = NumericProperty(0.0)

    _counter = count()

    def __init__(self, **kwargs):
        self._uid = next(RowButton._counter)
        super().__init__(**kwargs)

    def refresh_view_attrs(self, rv, index, data):
        old = self.index
        self.index = index
        super().refresh_view_attrs(rv, index, data)
        self.text = f"idx={index} _uid={self._uid}"
        print(f"uid={self._uid}, old_index={old} -> new_index={index}")

    def on_active(self, _, active):
        Animation.cancel_all(self, "pulse")
        if active:
            a = Animation(pulse=1.0, d=1.5) + Animation(pulse=0.0, d=1.5)
            a.repeat = True
            a.start(self)
        else:
            self.pulse = 0.0


class Root(FloatLayout):
    recycle_view = ObjectProperty()

    def on_kv_post(self, _):
        data = [{"active": i == 3} for i in range(8)]
        self.recycle_view.data = data
        self.tick = 0
        Clock.schedule_interval(self.change_width, 1/2)

    def change_width(self, _):
        if (self.tick % 30) < 15:
            self.recycle_view.size_hint[0] -= 0.003
        else:
            self.recycle_view.size_hint[0] += 0.003
        self.tick += 1


class Test(App):
    def build(self):
        return Root()

if __name__ == "__main__":
    Test().run()

You will see that print result:

python
# initial state (before any resize):
uid=0, old_index=-1 -> new_index=0
uid=1, old_index=-1 -> new_index=1
uid=2, old_index=-1 -> new_index=2
uid=3, old_index=-1 -> new_index=3

# tick A — uids 0..3 land on indices 3, 2, 1, 0:
uid=0, old_index=2 -> new_index=1
uid=1, old_index=1 -> new_index=2
uid=2, old_index=0 -> new_index=3
uid=2, old_index=3 -> new_index=0
uid=1, old_index=2 -> new_index=1
uid=0, old_index=1 -> new_index=2
uid=3, old_index=0 -> new_index=3
uid=3, old_index=3 -> new_index=0

# tick B — uids 0..3 land on indices 2, 1, 0, 3 (reversed):
(same block, mirrored)

It is reverse, how I say before, because RecycleDataAdapter get cache by LIFO.

If you want to see horizontal case, than replace RecycleBoxLayout in KV by:

python
    RecycleBoxLayout:
        orientation: "horizontal"
        size_hint: (None, 1)
        size_hint_x: None
        width: self.minimum_width
        default_size: ("60dp", None)
        default_size_hint: (None, 1)
        spacing: "4dp"

And replace method Root.change_width by:

python
    def change_width(self, _):
        if (self.tick % 30) < 15:
            self.recycle_view.size_hint[1] -= 0.003
        else:
            self.recycle_view.size_hint[1] += 0.003
        self.tick += 1

Proposed fix I only can say how to fix the LIFO problem. In RecycleDataAdapter.get_view need to replace the _cached_views[viewclass].pop() to _cached_views[viewclass].pop(0). It is minimal fix which MAYBE solve the problem (in my case: yes). But this is a very inefficient solution if _cached_views has thousands widgets. And it is fully eliminates the visible permutation, but does not address the underlying over-invalidation.

BUT! pop(0) only masks the visible effect. Unnecessary re-layout still happens on every resize frame, clear_layout and invalidate methods are still triggered, and all visible rows still go through refresh_view_layout. For real fix should also requires accounting for the specific axis when comparing sizes within _catch_layout_trigger and the modified set_visible_views block; but that not enough, because in the current pipeline refresh_view_layout applies only to widgets from new (widgets that came from the adapter cache). If the redundant invalidate call were removed, widgets stay in remaining and their width never gets updated when the container width changed. A full fix requires:

  1. excluding the axis controlled by size_hint from the comparison process and applying the updated width to widgets in the remaining list as well
  2. accept the extra layout pass but fix the ordering, which is what pop(0) does

Additionally, even pop(0) does not fix the issue in the scrollable case. When the adapter has gone through a scroll, dirty_views contains widgets in non-index order. invalidate() extends _cached_views with dirty_views[cls].values() after self.views.values(), so the pool becomes a mix of generations, and no linear pop order can recover the original index↔widget mapping. In this case, the visible permutation persists even with pop(0).

The real fix is to not call invalidate() when the data is unchanged. This requires (1) fixing the axis-aware comparison in _catch_layout_trigger and in the changed block of set_visible_views, and (2) applying refresh_view_layout to views in the remaining list as well, because RecycleLayout.do_layout is disabled (assert False), and refresh_view_layout is currently the only code path that applies size/size_hint to visible widgets.

Verification (optional) You can check fixing of LIFO by:

python
def monkey_patch_fix_adapter_lifo():
    from kivy.uix.recycleview import views
    def get_view(self, index, data_item, viewclass):
        dirty_views = self.dirty_views
        if viewclass is None:
            return
        stale = False
        view = None

        if viewclass in dirty_views:
            dirty_class = dirty_views[viewclass]
            if index in dirty_class:
                view = dirty_class.pop(index)
            elif views._cached_views[viewclass]:
                # there: pop() -> pop(0)
                view, stale = views._cached_views[viewclass].pop(0), True
            elif dirty_class:
                view, stale = dirty_class.popitem()[1], True
        elif views._cached_views[viewclass]:
            # there: pop() -> pop(0)
            view, stale = views._cached_views[viewclass].pop(0), True

        if view is None:
            view = self.create_view(index, data_item, viewclass)
            if view is None:
                return

        if stale:
            self.refresh_view_attrs(index, data_item, view)
        return view
    views.RecycleDataAdapter.get_view = get_view

Call this before App run and restart the program. The bug will no longer reproduce in the fully-visible case. In the scrollable case, the permutation may still occur because dirty_views introduces a second source of non-index ordering (see Proposed fix above). This monkey-patch is only a demonstration of the LIFO half of the problem.

P.S. A third independent path exists. In RecycleLayout._catch_layout_trigger, the else branch (which work when instance is not a visible row, that is, when the layout manager itself changes size) call the rv.refresh_from_layout() with no arguments. This pushes {} into _refresh_flags['layout']. In RecycleBoxLayout.compute_layout, a non-empty list of empty dicts is treated as "nothing specific changed, redo everything" and trigger the self.clear_layout(), followed by the same invalidate() chain. This path is not guarded by _size_needs_update, so it will be call on every resize of the container along any axis, including the non-layout one. Blocking only the _size_needs_update path is not enough, the LM-side refresh_from_layout() must also skip when only the non-layout axis changed.

The related defect only become after invalidate() suppressed. RecycleLayout.do_layout have assert False statement - normal Kivy Layout never pass execute for RecycleBoxLayout. As a result, nothing computes the widget's size from size_hint * parent_size, only code path that access view.size is RecycleLayout.refresh_view_layout, and it assign value stored in opt['size'].

For viewclass with default_size_hint: (1, None) and default_size: (None, 30dp), opt['size'][0] is initialized by compute_sizes_from_data to initial_width (100 by default) and will be never updated by compute_layout, the branch elif shnh is None: opt['size'] = [s[0], h] refresh only the height component and keeps s[0] unch