endDrag method

void endDrag()

Commits the drop: mutates treeController (via TreeController.moveNode, TreeController.reorderChildren, or TreeController.reorderRoots) and starts the FLIP slide animation to interpolate old to new positions.

If no valid target is currently resolved, behaves like cancelDrag.

The slide is installed IN-FRAME by the sliver render object: this method asks the render port to capture a baseline of current painted offsets BEFORE mutating the controller; the next performLayout (triggered by that mutation) snapshots the post-mutation offsets and installs a FLIP slide from baseline to current. The paint pass of the same frame then renders rows at their prior painted position and slides them toward their new structural position smoothly, with no one-frame "jump to new position, then slide back" flicker.

Implementation

void endDrag() {
  final session = _session;
  if (session == null) {
    return;
  }

  // Re-resolve against CURRENT tree state, then validate, BEFORE staging
  // the FLIP baseline. The last pointer-move's target may be stale: with
  // server-driven updates the dragged node or the target parent can have
  // become pending-deletion (or been purged) since. Committing a stale
  // target would throw out of a gesture callback with the session
  // permanently stuck, and a baseline staged before validation
  // would be consumed by nobody: first-wins staging then blocks every
  // subsequent slide stage until an unrelated layout flushes it.
  session.resolveTargetOnly();
  final target = session.currentTarget;
  final dragged = session.draggedKey;
  if (target == null) {
    cancelDrag();
    return;
  }
  final commitAllowed = _canCommit(dragged, target.parentKey);
  // `_canCommit` runs `canReorder`, which is app code. If it tore this
  // session down, everything below (baseline staging, the make-room
  // snap, `_applyMove`, and `detachAll` in the `finally`) would run
  // against a session that no longer exists: a second `detachAll`
  // double-disposes the autoscroll ticker and throws out of the
  // `finally`, which skips `_fireOnReorder` entirely. The tree gets
  // mutated and the app is never told.
  if (!identical(_session, session)) {
    return;
  }
  if (!commitAllowed) {
    cancelDrag();
    return;
  }

  // Current-position drop: the resolver now reports the dragged row's
  // own slot as a valid target ("returns here" feedback), but committing
  // it must mutate NOTHING, and must stage NO baseline (an unconsumed
  // baseline is exactly the protocol violation the expiry backstop
  // guards against). cancelDrag is the precise semantic: settle-back
  // glide, make-room release, clean teardown.
  // Compare the RESOLVED index, the way `moveTo` does. The resolver
  // never emits an out-of-range one today, so this is equivalence
  // rather than a fix; it stops the guard drifting from `_applyMove`'s
  // clamp for whatever calls this next.
  if (treeController.getParent(dragged) == target.parentKey &&
      target.indexInFinalList.clamp(
            0,
            (target.parentKey == null
                    ? treeController.liveRootCount
                    : treeController.liveChildCount(
                        target.parentKey as TKey,
                      )) -
                1,
          ) ==
          treeController.getIndexInParent(dragged)) {
    cancelDrag();
    return;
  }

  // The autoscroll ticker stops in detachAll(commit) below; endDrag is
  // synchronous, so no tick can interleave before then.

  // Dead-commit-slide settle fallback: when the commit FLIP cannot run
  // (its reorderSlide family is zeroed, whether captured or live) but
  // the drop-settle family is live and a proxy settler exists, the handoff
  // glide is installed DIRECTLY after the mutation instead of riding a
  // baseline override. The baseline MUST NOT be staged in this mode
  // (and the mutation must not self-stage): a dead baseline's consume
  // runs the slide engine's disabled clear-all and would wipe the
  // just-installed glide one frame later.
  final style = treeController.animationStyle;
  final useSettleFallback =
      (session.commitSlideSpec.duration == Duration.zero ||
          style.reorderSlide.duration == Duration.zero) &&
      session.settler != null &&
      style.effectiveDropSettle.duration != Duration.zero;

  // Stage the FLIP baseline BEFORE mutating. The render object's next
  // performLayout consumes it and installs the slide in-frame, avoiding
  // the post-frame gap that would flicker each moved row at its
  // destination for one frame.
  //
  // Proxy drop-settle: overriding the dragged row's baseline entry to
  // the RELEASE position (pointer minus grab offset, which is exactly
  // where the floating proxy is at this instant) carries the row from
  // the user's hand into its new slot, instead of replaying the
  // old-slot to new-slot reparent slide underneath the vanishing
  // proxy.
  if (!useSettleFallback) {
    session.renderPort.beginSlideBaseline(
      duration: session.commitSlideSpec.duration,
      curve: session.commitSlideSpec.curve,
      // Proxy drop-settle: the dragged row's FLIP starts at the release
      // position (null when there is no settler, or the scrollable is
      // gone, which means the classic old-slot FLIP). The override's x
      // is the proxy's visual cross
      // offset, or null (preserve the captured x) when the session has
      // no proxy cross-offset source.
      baselineOverrides: session.settler?.baselineOverrides(),
    );
  }

  // Make-room handoff: the baseline above captured the SHIFTED painted
  // positions (preview offsets ride the composed slide-delta read).
  // Snap the preview away now, BEFORE the mutation: the consume-time
  // snapshot then reads clean post-mutation structural positions, and
  // rows already previewing at their destination get ~zero FLIP deltas
  // (no jump, no double animation). This ordering is why the snap is a
  // named commit-script op rather than part of teardown.
  session.makeRoomDriver?.snapForCommit();

  ({TKey key, TKey? newParent, int index})? committed;
  try {
    // Same-parent drops keep the structural commit a SNAP: the
    // reorderable widget owns the drop animation, so animating here
    // would double-animate the row. Cross-parent keeps moveNode's own
    // baseline self-staging except in the settle fallback, where it
    // carries the same dead-consume hazard as the skipped staging
    // above (on the normal path it is first-wins-shadowed by it).
    final sameParent = treeController.getParent(dragged) == target.parentKey;
    committed = _applyMove(
      dragged,
      target.parentKey,
      target.indexInFinalList,
      animate: sameParent ? false : !useSettleFallback,
    );
    if (useSettleFallback) {
      // No pending-baseline guard needed since the disabled-mode
      // split: a foreign same-frame baseline's consume RE-BASES the
      // glide instead of clearing it (zero-family installs create no
      // motion but never destroy other families' motion). The
      // fallback e2e pins (glide install / frame survival) police
      // this script's own no-staging rule.
      session.settler?.glideIntoCommittedSlot();
    }
  } finally {
    // The re-resolve + validation above makes the commit's throwing
    // paths unreachable, so an exception here is a genuine invariant
    // violation: let it propagate, but never leave the session stuck.
    session.detachAll(SessionExit.commit);
    _pointerPosition.value = null;
    _session = null;
    // Report AFTER teardown, so a synchronous setState in the handler
    // cannot land mid-commit and a throwing handler cannot leave a
    // session stuck, and BEFORE the session-end notification, which
    // this must not suppress. A throwing MUTATION leaves `committed`
    // null and fires nothing, which is correct: no commit happened.
    try {
      if (committed != null) {
        _fireOnReorder(committed);
      }
    } finally {
      notifyListeners();
    }
  }
}