Skip to content

[Discussions] How to get/calculate alignment inside onObserve? #154

Closed

Description

@yerzhant

These two (including the commented one) cases didn't work out:

onObserve: (model) {
                if (model is! ListViewObserveModel) return;

                final first = model.firstChild!;

                // final leading = listModel.firstChild!.leadingMarginToViewport;
                // final trailing = listModel.firstChild!.trailingMarginToViewport;

                // var alignment = 0.0;

                // if (leading.abs() + trailing.abs() > 0) {
                //   alignment = leading.abs() / (leading.abs() + trailing.abs());
                // }

                final maxDistance =
                    first.viewportMainAxisExtent - first.size.height;

                var alignment = 0.0;

                if (maxDistance > 0) {
                  alignment = first.leadingMarginToViewport / maxDistance;
                }

                print('============== a $alignment');
              },

Activity

  1. LinXunFeng commented on Sep 7, 2026

    @LinXunFeng
    Member

    Your formula is actually correct — it is the standard inverse of Flutter's ensureVisible alignment. Two separate things are tripping you up:

    1. You apply it to model.firstChild, which is almost always clipped by the leading edge of the viewport, so leadingMarginToViewport <= 0 and the result can only ever be <= 0.
    2. Even with a correct value, that is not the quantity this package's alignment parameter takes. Here alignment is relative to the item's own main-axis size, not to the viewport.

    What alignment means in this package

    See observer_controller.dart#L986:

    double targetItemLeadingPadding = childSize * alignment;
    var targetOffset = childLayoutOffset + precedingScrollExtent + targetItemLeadingPadding;

    Vertical ListView, viewport 300 tall, every item 100 tall — item i occupies scroll range [100i, 100i + 100). One text row below = 50px, so one item = 2 rows.

    jumpTo(index: 3, alignment: 0.0) → pixels = 300 — item 3's leading edge on the viewport's leading edge:

          item 2 ░░░░░░░░░
          item 2 ░░░░░░░░░
        ╔═══════════════════╗ ← viewport top, pixels = 300
        ║ item 3 █████████  ║   firstChild.index        = 3
        ║ item 3 █████████  ║   leadingMarginToViewport = 0
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║
        ║ item 5 ▒▒▒▒▒▒▒▒▒  ║
        ║ item 5 ▒▒▒▒▒▒▒▒▒  ║
        ╚═══════════════════╝
    

    alignment: 0.5 → pixels = 350 — half of item 3 scrolled past the top edge:

          item 2 ░░░░░░░░░
          item 2 ░░░░░░░░░
          item 3 █████████    ← top half of item 3 is clipped away
        ╔═══════════════════╗ ← viewport top, pixels = 350
        ║ item 3 █████████  ║   firstChild.index        = 3
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║   leadingMarginToViewport = -50
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║
        ║ item 5 ▒▒▒▒▒▒▒▒▒  ║
        ║ item 5 ▒▒▒▒▒▒▒▒▒  ║
        ║ item 6 ░░░░░░░░░  ║
        ╚═══════════════════╝
    

    alignment: 1.0 → pixels = 400 — item 3's trailing edge on the viewport's leading edge, i.e. fully scrolled out:

          item 2 ░░░░░░░░░
          item 3 █████████    ← item 3 is completely above the viewport
          item 3 █████████
        ╔═══════════════════╗ ← viewport top, pixels = 400
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║   firstChild.index        = 4
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║   leadingMarginToViewport = 0
        ║ item 5 ▒▒▒▒▒▒▒▒▒  ║
        ║ item 5 ▒▒▒▒▒▒▒▒▒  ║
        ║ item 6 ░░░░░░░░░  ║
        ║ item 6 ░░░░░░░░░  ║
        ╚═══════════════════╝
    

    For contrast, alignment: 0.5 in Flutter's ensureVisible (viewport-relative) centres item 3, pixels = 200. Same number, completely different result:

          item 1 ▒▒▒▒▒▒▒▒▒
          item 1 ▒▒▒▒▒▒▒▒▒
        ╔═══════════════════╗ ← viewport top, pixels = 200
        ║ item 2 ░░░░░░░░░  ║   firstChild.index        = 2
        ║ item 2 ░░░░░░░░░  ║   leadingMarginToViewport = 0
        ║ item 3 █████████  ║ ← item 3 sits in the middle
        ║ item 3 █████████  ║
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║
        ║ item 4 ▓▓▓▓▓▓▓▓▓  ║
        ╚═══════════════════╝
    

    The two values side by side

    Scrolling through the same range, watching item 3 (firstChild is the first displaying child):

    offset.pixels firstChild.index leadingMarginToViewport package alignment
    -leading / mainAxisSize
    viewport-relative
    leading / (vpExtent - size)
    300 3 0 0.00 0.000
    325 3 -25 0.25 -0.125
    350 3 -50 0.50 -0.250
    375 3 -75 0.75 -0.375
    400 4 0 0.00 0.000

    The right-hand column is your formula. It is not wrong — it is just measuring something that is always <= 0 for firstChild. And feeding it into jumpTo would trip assert(alignment.clamp(0, 1) == alignment) at observer_controller.dart#L405.

    Your formula, applied to a specific item

    If viewport-relative alignment is what you want, use displayingChildModelMap to pick the item you care about instead of firstChild. Dropped into the CustomScrollView demo, watching item 12:

    // Item 12's viewport-relative alignment, i.e. the semantics of
    // Flutter's `ensureVisible`. It is null when item 12 is not displaying.
    final item12 = model.displayingChildModelMap[12];
    if (item12 == null) {
      debugPrint('item12 alignment(viewport) -- not displaying');
    } else {
      final maxDistance = item12.viewportMainAxisExtent - item12.mainAxisSize;
      final alignment12 = maxDistance <= 0
          ? 0.0
          : item12.leadingMarginToViewport / maxDistance;
      debugPrint('item12 alignment(viewport) -- $alignment12');
    }

    Scroll and you'll see it go ~1.0 (item 12 entering from the bottom) → 0.0 (its top edge on the viewport's top edge) → negative (scrolling further, item 12 clipped by the top edge). That last stretch is exactly the range firstChild lives in, which is why your original version only ever printed <= 0.

    Note mainAxisSize instead of size.height — it already picks the right axis, so the same code works for horizontal lists.

    The package's own alignment, inverted

    If the goal is saving and restoring the scroll position via (index, alignment), this is the one you want:

    onObserve: (model) {
      final first = model.firstChild;
      if (first == null || first.mainAxisSize == 0) return;
    
      final index = first.index;
      final alignment =
          (-first.leadingMarginToViewport / first.mainAxisSize).clamp(0.0, 1.0);
    
      // restore later:
      // observerController.jumpTo(index: index, alignment: alignment);
    }

    (leadingMarginToViewport is layoutOffset + precedingScrollExtent - viewportPixels, see ObserveDisplayingChildModelMixin.)

    For the child sitting at the leading edge — the only one whose package alignment falls in [0, 1] — the two are related by

    alignmentViewport = -mainAxisSize * alignmentItem / (viewportMainAxisExtent - mainAxisSize)
    

    so alignmentItem ∈ [0, 1] maps to alignmentViewport <= 0; they only agree at 0. For any other child (like item 12 above, sitting fully inside the viewport) there is no package alignment to convert from — compute the viewport-relative value directly from leadingMarginToViewport.

    Two last notes:

    • Near the end of the list jumpTo is clamped to maxScrollExtent, so an (index, alignment) round trip is not exact there.
    • As the sketches show, (index: 3, alignment: 1.0) and (index: 4, alignment: 0.0) are the same position — expected, not a bug.
  2. yerzhant commented on Sep 8, 2026

    @yerzhant
    Author

    I appreciate you for the comprehensive answer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions