PAYATHON 2026

iOS 26 捏合缩放时 UIHostingController 文本大小和位置异常

数据小陈

应先将 SwiftUI 的逻辑布局尺寸与画板元素的缩放、旋转状态分开,并移除布局完成后无条件刷新内容的路径。现有代码还不足以确认这是 Apple 已记录的 iOS 26 回归,也无法证明必须改用快照或纯 SwiftUI。

示例已经在缩放外层 contentContainer。增加一层 UIView 无法保证解决问题;需要检查缩放时内部尺寸、约束、字体参数或状态是否仍在变化。

为什么只有实时编辑界面出问题?

Widget、缩略图和实时编辑界面经过的视图生命周期、尺寸测量及状态更新路径可能不同。前两者正常有助于缩小排查范围,但不能单凭这一点断定 SwiftUI 文本实现没有问题,或系统 transform 存在缺陷。

可以先检查编辑界面独有的三条路径:

  • 手势更新是否把缩放后的视觉尺寸写回了元素的逻辑尺寸。
  • layoutSubviews() 是否反复要求 hosting view 立即布局。
  • viewDidLayoutSubviews 中的刷新是否又修改了 rootView、模型或约束。

新建元素时已有文本也会缩小,说明问题可能不只发生在捏合回调内。还应检查添加元素后触发的整板布局、共享状态更新和元素复用逻辑。这些是根据症状提出的排查方向,还不能确定根因。

transform 应不应该改变文本换行?

如果目标只是整体缩放元素,文本应随元素一起放大或缩小,逻辑排版宽度则应保持不变。

Apple 文档说明,UIView.transform 不影响 Auto Layout,布局使用未变换的几何信息。因此,单独设置 transform 不应被当作修改文本排版宽度的方式。UIView.transform 文档

检查是否有类似代码:

// 不应使用已变换视图的 frame 来反推内容布局尺寸。
let layoutSize = contentContainer.frame.size

当某个视图自身具有非单位 transform 时,Apple 明确要求忽略其 frame,并使用 bounds 和 center 调整尺寸与位置。UIView.frame 文档

这一限制针对具有 transform 的那个视图。示例中的 view.frame = contentContainer.bounds 不能直接判定为错误:如果子视图自身没有 transform,使用父容器的本地 bounds 布局子视图可以成立。需要检查未展示的代码是否读取了 contentContainer.frame,再将结果用于约束、SwiftUI .frame 或字体计算。

整体缩放应保持排版尺寸;调整文本框宽度时则允许重新换行。编辑器应分别存储这两类状态。

layoutIfNeeded() 会导致文本重新测量吗?

它可能参与触发测量,但不能把它本身视为字号异常的原因。

layoutIfNeeded() 会立即处理接收视图子树中待完成的布局;没有待处理更新时,它会直接返回。原代码先调用 setNeedsLayout(),再调用 layoutIfNeeded(),相当于主动安排并立即执行一次布局。layoutIfNeeded() 文档

如果 SwiftUI 的内容、可用尺寸或环境此时已经失效,承载的内容可能需要重新测量。是否实际发生,以及为什么得到不同结果,需要结合具体 SwiftUI 内容判断。

原代码在每次 layoutSubviews() 中都会重复执行这组调用。描述中还有一条未展示的 viewDidLayoutSubviews 刷新路径。如果刷新再次修改内容或尺寸,就可能形成反馈:

布局完成
  → 刷新内容或模型
  → 内容或尺寸失效
  → 再次布局
  → 再次刷新

应先移除无条件刷新,让内容更新由真实的数据变化驱动。确实需要响应尺寸变化时,可以比较上一次逻辑尺寸,只处理实际变化,并避免把视觉缩放结果写回逻辑尺寸。

DispatchQueue.main.async 也不能保证“布局已稳定”。它只把操作延后执行,仍可能与后续状态更新交错。

如何整理 hosting controller 的生命周期?

原代码先把 hc.view 加入视图层级,等到 didMoveToWindow() 再通过 responder chain 查找父控制器并调用 addChild。建议改为由编辑器控制器显式管理子控制器。

标准嵌入顺序是:addChild、添加子控制器的 view、设置布局、didMove(toParent:)。移除时执行对应的解除约束和父子关系操作。Apple 容器控制器指南

这能减少首次测量和父级环境建立之间的时序变化,但不能据此断言原顺序就是此次异常的唯一原因。

另外,sizingOptions = [] 表示使用空的尺寸跟踪选项,也是该属性的默认值。它不会冻结 SwiftUI 布局,也不会禁止文本重新测量。sizingOptions 文档

稳定布局与视觉变换可以怎样实现?

下面的示例用元素的 bounds 表示逻辑尺寸,用 presentationView.transform 表示视觉变换,hosting view 通过约束填满内部空间。布局回调中没有重设内容或强制布局。

import UIKit
import SwiftUI

@MainActor
final class StableHostedItem: UIView {
    private let presentationView = UIView()
    private var host: UIHostingController<AnyView>?
    private var hostConstraints: [NSLayoutConstraint] = []

    init(logicalSize: CGSize) {
        super.init(frame: CGRect(origin: .zero, size: logicalSize))
        addSubview(presentationView)
        presentationView.bounds = CGRect(origin: .zero, size: logicalSize)
        presentationView.center = CGPoint(x: bounds.midX, y: bounds.midY)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let logicalBounds = CGRect(origin: .zero, size: bounds.size)
        if presentationView.bounds != logicalBounds {
            presentationView.bounds = logicalBounds
        }
        presentationView.center = CGPoint(x: bounds.midX, y: bounds.midY)
    }

    func install<Content: View>(
        _ content: Content,
        parent: UIViewController
    ) {
        precondition(host == nil)
        precondition(isDescendant(of: parent.view))

        let hc = UIHostingController(rootView: AnyView(content))
        hc.view.backgroundColor = .clear
        if #available(iOS 16.0, *) {
            hc.sizingOptions = []
        }

        parent.addChild(hc)
        presentationView.addSubview(hc.view)
        hc.view.translatesAutoresizingMaskIntoConstraints = false

        hostConstraints = [
            hc.view.leadingAnchor.constraint(equalTo: presentationView.leadingAnchor),
            hc.view.trailingAnchor.constraint(equalTo: presentationView.trailingAnchor),
            hc.view.topAnchor.constraint(equalTo: presentationView.topAnchor),
            hc.view.bottomAnchor.constraint(equalTo: presentationView.bottomAnchor)
        ]
        NSLayoutConstraint.activate(hostConstraints)
        hc.didMove(toParent: parent)
        host = hc
    }

    func updateContent<Content: View>(_ content: Content) {
        host?.rootView = AnyView(content)
    }

    func applyTransform(scale: CGFloat, rotation: CGFloat) {
        presentationView.transform = CGAffineTransform(rotationAngle: rotation)
            .scaledBy(x: scale, y: scale)
    }

    func removeHostedContent() {
        guard let hc = host else { return }
        hc.willMove(toParent: nil)
        NSLayoutConstraint.deactivate(hostConstraints)
        hostConstraints.removeAll()
        hc.view.removeFromSuperview()
        hc.removeFromParent()
        host = nil
    }
}

使用时先把元素加入编辑器控制器的视图层级,再调用 install(_:parent:)。删除元素时调用 removeHostedContent(),随后移除元素视图。

捏合回调只更新 scale 和 rotation。只有用户真正调整文本框尺寸时,才更新逻辑 bounds 或对应尺寸约束。代码中的 rotation 使用弧度,scale 应由手势层保证为有效正数。

这个示例明确了尺寸和生命周期的边界,但尚未在题述应用中验证,不能保证消除某个系统版本的渲染缺陷。hosting view 自身的 transform 即使保持 .identity,仍会随祖先视图一起缩放。

快照、栅格化和纯 SwiftUI 应怎样选择?

方案适用条件需要考虑的限制
稳定逻辑尺寸,缩放外层容器希望保留实时内容、交互与现有 UIKit 编辑器仍需消除尺寸回写和布局反馈,并验证目标系统表现
手势期间显示快照内容可在手势期间暂时保持静态,且实时承载路径仍有可复现异常放大清晰度、内存、动态内容、交互与无障碍切换
纯 SwiftUI 编辑路径编辑器整体适合统一到 SwiftUI 状态和手势体系迁移成本较高,布局与状态反馈问题仍需正确处理

快照可以在视觉上隔离实时内容更新,但仅隐藏 hosting view 不等于停止其布局或状态更新。若要避免手势期间的反馈,还需要控制对应的刷新路径。

layer.shouldRasterize = true 是渲染缓存设置,不能阻止约束求解、SwiftUI 内容更新或尺寸测量,也不会冻结布局。因此,不宜把栅格化当作布局问题的确定修复。

怎样进一步确认根因?

可以按以下顺序缩小范围,这比直接迁移编辑器更容易得到可验证的结论:

  1. 暂停 viewDidLayoutSubviews 中的内容刷新,并移除 layoutSubviews() 中无条件的 setNeedsLayout() 和 layoutIfNeeded()。
  2. 记录元素逻辑尺寸、hosting view 的 bounds、尺寸约束和 transform;确认捏合时逻辑尺寸是否保持不变。
  3. 暂时使用固定内容与固定 .font(.system(size: 20)),排除业务字号计算;随后逐步恢复 GeometryReader、.minimumScaleFactor、动态字体和尺寸偏好回传等实际使用的逻辑。
  4. 单独复现“新增一个元素导致已有元素缩小”,检查已有元素是否被重建、重新配置或收到共享模型更新。
  5. 在相同项目和可控构建条件下比较系统版本,记录具体 iOS、Xcode、SDK 与设备信息。

如果最小示例具备完整的 containment、稳定的非零尺寸和固定内容,且没有应用层刷新反馈,却仍只在特定 iOS 版本复现,就更有依据怀疑系统回归。此时可以保留最小工程并提交 Feedback,再根据交互需求决定是否在手势期间采用快照兜底。

备注:内容仅供参考。