Skip to content

[cupertino_ui] CupertinoMagnifier inOutAnimation scale is ignored due to Offset immutability #193767

Closed
flutter/packages
#13118

Description

@Mairramer

While reviewing flutter/packages#13029, Gemini pointed out an existing issue here: flutter/packages#13029 (comment)

In CupertinoMagnifier, focalPointOffset.scale() returns a new Offset instead of modifying the existing one. As a result, the animation scale is currently ignored.

The problematic code is:

var focalPointOffset = Offset(
  0,
  (size.height / 2) - kMagnifierAboveFocalPoint,
);
focalPointOffset.scale(1, inOutAnimation?.value ?? 1); // No effect
focalPointOffset += additionalFocalPointOffset;

Expected Behavior

The focalPointOffset should be scaled correctly by the animation value.

Proposed Fix

The return value of .scale() should be assigned back to the variable, or chained directly during the initialization of focalPointOffset:

var focalPointOffset = Offset(0, (size.height / 2) - kMagnifierAboveFocalPoint)
    .scale(1, inOutAnimation?.value ?? 1);
focalPointOffset += additionalFocalPointOffset;

A corresponding widget test should also be added to assert that rawMagnifier.focalPointOffset correctly scales when inOutAnimation is set.

Activity

  1. added
    in triagePresently being triaged by the triage team
    packageflutter/packages repository. See also p: labels.
    team-designOwned by Design Languages team
    and removed
    in triagePresently being triaged by the triage team
    on Oct 5, 2026
  2. added
    P2Important issues not at the top of the work list
    triaged-designTriaged by Design Languages team
    p: cupertino_uicupertino_ui package in flutter/packages
    packageflutter/packages repository. See also p: labels.
    and removed
    p: cupertino_uicupertino_ui package in flutter/packages
    packageflutter/packages repository. See also p: labels.
    P2Important issues not at the top of the work list
    team-designOwned by Design Languages team
    on Oct 6, 2026
  3. saurabh-mirajkar commented on Oct 7, 2026

    @saurabh-mirajkar
    Contributor

    @QuncCccccc looks like the team label got removed after adding priority; re-adding it

  4. dkwingsmt commented on Oct 12, 2026

    @dkwingsmt
    Contributor

    Note: I only tested the Liquid Glass loupe on iOS 26. iOS 18 may behave differently, but we're no longer targeting iOS 18 fidelity, so this comment is only about iOS 26.

    The fidelity issue is real, but this fix doesn't solve it. It changes which way the magnified content drifts without stopping the drift.

    The following video is the native loupe on the simulator. As it appears, the loupe rises from just above the touch point while fading in and growing. In every frame, the magnified content stays centered on the touch point and moves together with the lens.

    Screen.Recording.2026-10-11.at.7.14.42.PM.mov
    Example SwiftUI code
    import SwiftUI
    import UIKit
    
    
    final class LoupeModel: ObservableObject {
      weak var textView: UITextView?
      weak var marker: UIView?
      private var session: UITextLoupeSession?
    
      @Published var focalY: CGFloat = 220 { didSet { layoutMarker() } }
      @Published var showMarker = true { didSet { marker?.isHidden = !showMarker } }
      @Published var trackCaret = true
      /// Core Animation speed applied to every window of the app, including the
      /// window the loupe is rendered in (re-applied on every begin).
      @Published var speed: Double = 0.05 { didSet { applySpeed() } }
    
      func applySpeed() {
        for scene in UIApplication.shared.connectedScenes {
          guard let windowScene = scene as? UIWindowScene else { continue }
          for window in windowScene.windows {
            window.layer.speed = Float(speed)
          }
        }
      }
    
      func layoutMarker() {
        guard let textView, let marker else { return }
        marker.frame = CGRect(x: 0, y: focalY - 1, width: textView.bounds.width, height: 2)
      }
    
      func begin() {
        guard let textView else { return }
        session?.invalidate()
        applySpeed()
        let point = CGPoint(x: textView.bounds.midX, y: focalY)
        session = UITextLoupeSession.begin(at: point, fromSelectionWidgetView: nil, in: textView)
        if trackCaret, let position = textView.closestPosition(to: point) {
          // Mimics a caret drag: the lens snaps to the line under the point,
          // like CupertinoTextMagnifier does with caretRect.
          let caret = textView.caretRect(for: position)
          session?.move(to: point, withCaretRect: caret, trackingCaret: true)
        }
        // The loupe may live in a window created lazily by begin().
        DispatchQueue.main.async { self.applySpeed() }
      }
    
      func end() {
        applySpeed()
        session?.invalidate()
        session = nil
      }
    }
    
    struct RulerTextView: UIViewRepresentable {
      let model: LoupeModel
    
      func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.isEditable = false
        textView.isSelectable = true
        // Scrollable so it has no intrinsic height; never scrolled, so bounds
        // coordinates equal content coordinates.
        textView.isScrollEnabled = true
        textView.textContainerInset = .zero
        textView.textContainer.lineFragmentPadding = 0
        textView.attributedText = Self.rulerText()
    
        let marker = UIView()
        marker.backgroundColor = .systemRed
        marker.isUserInteractionEnabled = false
        textView.addSubview(marker)
    
        model.textView = textView
        model.marker = marker
        DispatchQueue.main.async { model.layoutMarker() }
        return textView
      }
    
      func updateUIView(_ uiView: UITextView, context: Context) {
        model.layoutMarker()
      }
    
      /// 12pt-tall numbered rows with alternating colors, so a shift of the focal
      /// point is readable inside the lens.
      static func rulerText() -> NSAttributedString {
        let paragraph = NSMutableParagraphStyle()
        paragraph.minimumLineHeight = 12
        paragraph.maximumLineHeight = 12
        let font = UIFont.monospacedSystemFont(ofSize: 10, weight: .regular)
        let result = NSMutableAttributedString()
        for i in 0..<120 {
          let line = String(format: "%03d ", i) + String(repeating: i.isMultiple(of: 2) ? "=" : "-", count: 40) + "\n"
          result.append(NSAttributedString(string: line, attributes: [
            .font: font,
            .paragraphStyle: paragraph,
            .backgroundColor: i.isMultiple(of: 2)
              ? UIColor(red: 1, green: 0, blue: 0.88, alpha: 0.35)
              : UIColor(red: 0, green: 0.88, blue: 1, alpha: 0.35),
          ]))
        }
        return result
      }
    }
    
    struct LoupeTestView: View {
      @StateObject private var model = LoupeModel()
    
      var body: some View {
        VStack(spacing: 8) {
          RulerTextView(model: model)
          HStack {
            Button("Begin loupe") { model.begin() }.buttonStyle(.borderedProminent)
            Button("End loupe") { model.end() }.buttonStyle(.bordered)
          }
          HStack {
            Text("focal y \(Int(model.focalY))").monospacedDigit().frame(width: 90, alignment: .leading)
            Slider(value: $model.focalY, in: 60...600)
          }
          HStack {
            Text("speed \(model.speed, specifier: "%.2f")").monospacedDigit().frame(width: 90, alignment: .leading)
            Slider(value: $model.speed, in: 0.02...1)
          }
          HStack {
            Toggle("Red marker", isOn: $model.showMarker)
            Toggle("Track caret", isOn: $model.trackCaret)
          }
        }
        .padding(.horizontal)
        .preferredColorScheme(.light)
        .task {
          // `-autoLoupe` loops begin/end so the animation can be recorded with
          // `xcrun simctl io booted recordVideo` without tapping.
          guard ProcessInfo.processInfo.arguments.contains("-autoLoupe") else { return }
          while !Task.isCancelled {
            try? await Task.sleep(for: .seconds(1))
            model.begin()
            try? await Task.sleep(for: .seconds(1.5 / max(model.speed, 0.02) * 0.2 + 1))
            model.end()
            try? await Task.sleep(for: .seconds(1.5 / max(model.speed, 0.02) * 0.2 + 1))
          }
        }
      }
    }
    
    
    

    In Flutter today, the magnified content slides the opposite way from the lens during the appear animation. With this fix it moves the same way as the lens, which still doesn't match native. The fix needs to cancel out the lens's own movement so the touch point stays centered throughout the animation.

    The following is the current master:

    Screen.Recording.2026-10-11.at.7.16.37.PM.mov

    The following is after the suggested fix:

    Screen.Recording.2026-10-11.at.7.17.16.PM.mov
    Example Flutter code
    import 'package:cupertino_ui/cupertino_ui.dart';
    
    void main() => runApp(const CupertinoApp(home: ReproPage()));
    
    class ReproPage extends StatefulWidget {
      const ReproPage({super.key});
    
      @override
      State<ReproPage> createState() => _ReproPageState();
    }
    
    class _ReproPageState extends State<ReproPage> {
      final GlobalKey _magnifierKey = GlobalKey();
      double _value = 1.0;
      double? _actualDy;
    
      void _readActualFocalPoint(Duration _) {
        RawMagnifier? raw;
        void visit(Element e) {
          if (e.widget is RawMagnifier) {
            raw = e.widget as RawMagnifier;
          } else {
            e.visitChildren(visit);
          }
        }
    
        _magnifierKey.currentContext?.visitChildElements(visit);
        final double? dy = raw?.focalPointOffset.dy;
        if (mounted && dy != _actualDy) {
          setState(() => _actualDy = dy);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        WidgetsBinding.instance.addPostFrameCallback(_readActualFocalPoint);
        final double expectedDy =
            (CupertinoMagnifier.kDefaultSize.height / 2 -
                CupertinoMagnifier.kMagnifierAboveFocalPoint) *
            _value;
    
        return CupertinoPageScaffold(
          child: SafeArea(
            child: Column(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    children: <Widget>[
                      Text('inOutAnimation.value = ${_value.toStringAsFixed(2)}'),
                      Text(
                        'expected focalPointOffset.dy = ${expectedDy.toStringAsFixed(2)}',
                      ),
                      Text(
                        'actual   focalPointOffset.dy = ${_actualDy?.toStringAsFixed(2) ?? '-'}',
                      ),
                      CupertinoSlider(
                        value: _value,
                        onChanged: (double v) => setState(() => _value = v),
                      ),
                    ],
                  ),
                ),
                Expanded(
                  child: Stack(
                    children: <Widget>[
                      // Numbered 10px rows so a focal point shift is visible in the lens.
                      Positioned.fill(
                        child: ListView.builder(
                          physics: const NeverScrollableScrollPhysics(),
                          itemExtent: 10,
                          itemBuilder: (BuildContext context, int i) => ColoredBox(
                            color: i.isEven
                                ? const Color(0xFFFF00E0)
                                : const Color(0xFF00E0FF),
                            child: Center(
                              child: Text(
                                '$i',
                                style: const TextStyle(fontSize: 8),
                              ),
                            ),
                          ),
                        ),
                      ),
                      Positioned(
                        left: 100,
                        top: 100,
                        child: CupertinoMagnifier(
                          key: _magnifierKey,
                          inOutAnimation: AlwaysStoppedAnimation<double>(_value),
                          magnificationScale: 1.5,
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    

    Suggested fix:

        final double animationValue = inOutAnimation?.value ?? 1.0;
        final double defaultYOffset = (size.height / 2) - kMagnifierAboveFocalPoint;
        // Cancel the downward translation below so the lens keeps showing the focal
        // point during the in/out animation, matching the native iOS 26 loupe.
        final focalPointOffset =
            Offset(0, defaultYOffset + kMagnifierAboveFocalPoint * (1 - animationValue)) +
            additionalFocalPointOffset;

    Not covered here: the content in the native loupe also shrinks as it disappears. It doesn't have to be part of this issue.

  5. github-actions commented on Oct 30, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  6. locked as resolved and limited conversation to collaborators on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listp: cupertino_uicupertino_ui package in flutter/packagespackageflutter/packages repository. See also p: labels.team-designOwned by Design Languages teamtriaged-designTriaged by Design Languages team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions