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 内容更新或尺寸测量,也不会冻结布局。因此,不宜把栅格化当作布局问题的确定修复。
怎样进一步确认根因?
可以按以下顺序缩小范围,这比直接迁移编辑器更容易得到可验证的结论:
- 暂停
viewDidLayoutSubviews中的内容刷新,并移除layoutSubviews()中无条件的setNeedsLayout()和layoutIfNeeded()。 - 记录元素逻辑尺寸、hosting view 的
bounds、尺寸约束和 transform;确认捏合时逻辑尺寸是否保持不变。 - 暂时使用固定内容与固定
.font(.system(size: 20)),排除业务字号计算;随后逐步恢复GeometryReader、.minimumScaleFactor、动态字体和尺寸偏好回传等实际使用的逻辑。 - 单独复现“新增一个元素导致已有元素缩小”,检查已有元素是否被重建、重新配置或收到共享模型更新。
- 在相同项目和可控构建条件下比较系统版本,记录具体 iOS、Xcode、SDK 与设备信息。
如果最小示例具备完整的 containment、稳定的非零尺寸和固定内容,且没有应用层刷新反馈,却仍只在特定 iOS 版本复现,就更有依据怀疑系统回归。此时可以保留最小工程并提交 Feedback,再根据交互需求决定是否在手势期间采用快照兜底。
备注:内容仅供参考。
