You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[cupertino_ui] CupertinoMagnifier inOutAnimation scale is ignored due to Offset immutability #193767
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:
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
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.
Activity
saurabh-mirajkar commentedon Oct 7, 2026
@QuncCccccc looks like the team label got removed after adding priority; re-adding it
dkwingsmt commentedon Oct 12, 2026
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
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
Suggested fix:
Not covered here: the content in the native loupe also shrinks as it disappears. It doesn't have to be part of this issue.
[cupertino_ui] Fix CupertinoMagnifier focalPointOffset animation scal…
github-actions commentedon Oct 30, 2026
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 -vand a minimal reproduction of the issue.