Skip to content

Commit e6bc93c

Browse files
committed
optimize SwiftUI.View+SizeThatFits
1 parent 92ddc65 commit e6bc93c

3 files changed

Lines changed: 382 additions & 6 deletions

File tree

ComposeUI/Sources/ComposeUI/SwiftUI/SwiftUI.View+SizeThatFits.swift

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,41 @@ public extension SwiftUI.View {
4646
/// - proposingSize: The proposing container size.
4747
/// - Returns: The fitting size of the view.
4848
func sizeThatFits(_ proposingSize: CGSize) -> CGSize {
49-
#if canImport(AppKit)
50-
return NSHostingController(rootView: self).sizeThatFits(in: proposingSize)
51-
#endif
52-
#if canImport(UIKit)
53-
return UIHostingController(rootView: self).sizeThatFits(in: proposingSize)
54-
#endif
49+
SwiftUISizingHostPool.shared.sizeThatFits(self, in: proposingSize)
50+
}
51+
}
52+
53+
#if canImport(AppKit)
54+
private typealias HostingController = NSHostingController
55+
#endif
56+
57+
#if canImport(UIKit)
58+
private typealias HostingController = UIHostingController
59+
#endif
60+
61+
/// A pool of `HostingController` instances used to measure SwiftUI views, to avoid per-call `HostingController` allocation cost.
62+
///
63+
/// A pool (rather than a single shared host) is used so that reentrant calls, e.g. a parent view's body calls `sizeThatFits` on a child
64+
/// while the parent itself is being measured, each get their own dedicated host controller.
65+
///
66+
/// The pool grows lazily to match the deepest observed reentrancy and stays at that size afterwards.
67+
@MainActor
68+
private final class SwiftUISizingHostPool {
69+
70+
static let shared = SwiftUISizingHostPool()
71+
72+
private var hosts: [HostingController<AnyView>] = []
73+
74+
private init() {}
75+
76+
func sizeThatFits(_ view: some SwiftUI.View, in proposingSize: CGSize) -> CGSize {
77+
let host = hosts.popLast() ?? HostingController(rootView: AnyView(EmptyView()))
78+
host.rootView = AnyView(view)
79+
defer {
80+
// drop the reference to the user's view before returning the host to the pool.
81+
host.rootView = AnyView(EmptyView())
82+
hosts.append(host)
83+
}
84+
return host.sizeThatFits(in: proposingSize)
5585
}
5686
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// UniversalTypesTests.swift
3+
// ComposéUI
4+
//
5+
// Created by Honghao Zhang on 5/9/26.
6+
// Copyright © 2024 Honghao Zhang.
7+
//
8+
// MIT License
9+
//
10+
// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz)
11+
//
12+
// Permission is hereby granted, free of charge, to any person obtaining a copy
13+
// of this software and associated documentation files (the "Software"), to
14+
// deal in the Software without restriction, including without limitation the
15+
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
16+
// sell copies of the Software, and to permit persons to whom the Software is
17+
// furnished to do so, subject to the following conditions:
18+
//
19+
// The above copyright notice and this permission notice shall be included in
20+
// all copies or substantial portions of the Software.
21+
//
22+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
27+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
28+
// IN THE SOFTWARE.
29+
//
30+
31+
#if canImport(AppKit)
32+
import AppKit
33+
#endif
34+
35+
#if canImport(UIKit)
36+
import UIKit
37+
#endif
38+
39+
import ChouTiTest
40+
41+
import ComposeUI
42+
43+
final class UniversalTypesTests: XCTestCase {
44+
45+
// MARK: - AppKit
46+
47+
#if canImport(AppKit)
48+
49+
func test_appKit_typealiases() {
50+
expect(ComposeUI.Window.self == NSWindow.self) == true
51+
expect(ComposeUI.View.self == NSView.self) == true
52+
expect(ComposeUI.TextView.self == NSTextView.self) == true
53+
expect(ComposeUI.Color.self == NSColor.self) == true
54+
expect(ComposeUI.Font.self == NSFont.self) == true
55+
expect(ComposeUI.FontDescriptor.self == NSFontDescriptor.self) == true
56+
expect(ComposeUI.BezierPath.self == NSBezierPath.self) == true
57+
expect(ComposeUI.GestureRecognizer.self == NSGestureRecognizer.self) == true
58+
expect(ComposeUI.TapGestureRecognizer.self == NSClickGestureRecognizer.self) == true
59+
expect(ComposeUI.PressGestureRecognizer.self == NSPressGestureRecognizer.self) == true
60+
expect(ComposeUI.PanGestureRecognizer.self == NSPanGestureRecognizer.self) == true
61+
}
62+
63+
func test_appKit_edgeInsets_isNSEdgeInsets() {
64+
let insets = ComposeUI.EdgeInsets(top: 1, left: 2, bottom: 3, right: 4)
65+
expect(insets.top) == 1
66+
expect(insets.left) == 2
67+
expect(insets.bottom) == 3
68+
expect(insets.right) == 4
69+
// confirm interop with NSEdgeInsets
70+
let nsInsets: NSEdgeInsets = insets
71+
expect(nsInsets.top) == 1
72+
}
73+
74+
func test_appKit_gestureRecognizerDelegate_protocolConformance() {
75+
final class Delegate: NSObject, ComposeUI.GestureRecognizerDelegate {}
76+
let delegate: NSGestureRecognizerDelegate = Delegate()
77+
expect(delegate).toNot(beNil())
78+
}
79+
80+
#endif
81+
82+
// MARK: - UIKit
83+
84+
#if canImport(UIKit)
85+
86+
func test_uiKit_typealiases() {
87+
expect(ComposeUI.Window.self == UIWindow.self) == true
88+
expect(ComposeUI.View.self == UIView.self) == true
89+
expect(ComposeUI.TextView.self == UITextView.self) == true
90+
expect(ComposeUI.Color.self == UIColor.self) == true
91+
expect(ComposeUI.Font.self == UIFont.self) == true
92+
expect(ComposeUI.FontDescriptor.self == UIFontDescriptor.self) == true
93+
expect(ComposeUI.BezierPath.self == UIBezierPath.self) == true
94+
expect(ComposeUI.GestureRecognizer.self == UIGestureRecognizer.self) == true
95+
expect(ComposeUI.TapGestureRecognizer.self == UITapGestureRecognizer.self) == true
96+
expect(ComposeUI.PressGestureRecognizer.self == UILongPressGestureRecognizer.self) == true
97+
expect(ComposeUI.PanGestureRecognizer.self == UIPanGestureRecognizer.self) == true
98+
}
99+
100+
func test_uiKit_edgeInsets_isUIEdgeInsets() {
101+
let insets = ComposeUI.EdgeInsets(top: 1, left: 2, bottom: 3, right: 4)
102+
expect(insets.top) == 1
103+
expect(insets.left) == 2
104+
expect(insets.bottom) == 3
105+
expect(insets.right) == 4
106+
// confirm interop with UIEdgeInsets
107+
let uiInsets: UIEdgeInsets = insets
108+
expect(uiInsets.top) == 1
109+
}
110+
111+
func test_uiKit_gestureRecognizerDelegate_protocolConformance() {
112+
final class Delegate: NSObject, ComposeUI.GestureRecognizerDelegate {}
113+
let delegate: UIGestureRecognizerDelegate = Delegate()
114+
expect(delegate).toNot(beNil())
115+
}
116+
117+
#endif
118+
}

0 commit comments

Comments
 (0)