-
-
Notifications
You must be signed in to change notification settings - Fork 538
Expand file tree
/
Copy pathFloatingPanelView.swift
More file actions
284 lines (248 loc) · 9.9 KB
/
FloatingPanelView.swift
File metadata and controls
284 lines (248 loc) · 9.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
// Copyright 2021 the FloatingPanel authors. All rights reserved. MIT license.
#if canImport(SwiftUI)
import SwiftUI
import Combine
/// A SwiftUI view that integrates a floating panel with customizable content.
///
/// ``FloatingPanelView`` provides a SwiftUI wrapper around the UIKit-based ``FloatingPanelController``,
/// allowing you to easily add floating panels to your SwiftUI interface. The view consists of
/// two main components:
///
/// - A main view that serves as the background or parent view
/// - A floating panel that contains custom content and can be positioned and animated
///
/// While you can use this view directly, it's recommended to use the ``SwiftUICore/View/floatingPanel(coordinator:onEvent:content:)``
/// view modifier instead, which provides a more SwiftUI-friendly API:
///
/// ```swift
/// MyView()
/// .floatingPanel { proxy in
/// // Your floating panel content
/// Text("Panel Content")
/// }
/// .floatingPanelLayout(MyCustomLayout())
/// .floatingPanelBehavior(MyCustomBehavior())
/// ```
///
/// You can also provide a custom coordinator and handle events:
///
/// ```swift
/// MyView()
/// .floatingPanel(
/// coordinator: MyCustomCoordinator.self,
/// onEvent: { event in
/// // Handle panel events
/// }
/// ) { proxy in
/// // Your floating panel content
/// }
/// ```
///
/// By default, ``FloatingPanelView`` uses ``FloatingPanelDefaultCoordinator`` to manage the
/// relationship between SwiftUI and UIKit components, but you can provide a custom
/// coordinator for more advanced control and event handling.
@available(iOS 14, *)
struct FloatingPanelView<MainView: View, ContentView: View>: UIViewControllerRepresentable {
/// A closure that creates the coordinator responsible for managing the floating panel.
let coordinator: () -> (any FloatingPanelCoordinator)
/// The view builder that creates the main content underneath the floating panel.
@ViewBuilder
var main: MainView
/// The view builder that creates the content displayed inside the floating panel.
@ViewBuilder
var content: (FloatingPanelProxy) -> ContentView
/// A binding to the floating panel's current anchor state.
@Environment(\.state)
private var state: Binding<FloatingPanelState?>
/// The layout object that defines the position and size of the floating panel.
@Environment(\.layout)
private var layout: FloatingPanelLayout
/// The behavior object that defines the interaction dynamics of the floating panel.
@Environment(\.behavior)
private var behavior: FloatingPanelBehavior
/// The behavior for determining the adjusted content insets in the panel.
@Environment(\.contentInsetAdjustmentBehavior)
private var contentInsetAdjustmentBehavior
/// Constants that define how a panel's content fills the surface.
@Environment(\.contentMode)
private var contentMode
/// The vertical padding between the grabber handle and the content.
@Environment(\.grabberHandlePadding)
private var grabberHandlePadding
/// The appearance configuration for the floating panel's surface view.
@Environment(\.surfaceAppearance)
private var surfaceAppearance
func makeCoordinator() -> FloatingPanelCoordinatorProxy {
return FloatingPanelCoordinatorProxy(
coordinator: coordinator(),
state: state
)
}
func makeUIViewController(context: Context) -> UIHostingController<MainView> {
let mainHostingController = UIHostingController(rootView: main)
mainHostingController.view.backgroundColor = nil
let contentHostingController = UIHostingController(rootView: content(context.coordinator.proxy))
context.coordinator.setupFloatingPanel(
mainHostingController: mainHostingController,
contentHostingController: contentHostingController
)
context.coordinator.observeStateChanges()
context.coordinator.update(layout: layout, behavior: behavior)
return mainHostingController
}
func updateUIViewController(
_ uiViewController: UIHostingController<MainView>,
context: Context
) {
context.coordinator.onUpdate(context: context)
applyEnvironment(context: context)
applyAnimatableEnvironment(context: context)
}
}
@available(iOS 14, *)
extension FloatingPanelView {
// MARK: - Environment updates
/// Applies environment values to the floating panel controller.
func applyEnvironment(context: Context) {
let fpc = context.coordinator.controller
if fpc.contentInsetAdjustmentBehavior != contentInsetAdjustmentBehavior {
fpc.contentInsetAdjustmentBehavior = contentInsetAdjustmentBehavior
}
if fpc.contentMode != contentMode {
fpc.contentMode = contentMode
}
if fpc.surfaceView.grabberHandlePadding != grabberHandlePadding {
fpc.surfaceView.grabberHandlePadding = grabberHandlePadding
}
if fpc.surfaceView.appearance != surfaceAppearance {
fpc.surfaceView.appearance = surfaceAppearance
}
}
/// Applies environment values to the floating panel controller with animations if needed.
func applyAnimatableEnvironment(context: Context) {
context.coordinator.apply(
animatableChanges: {
context.coordinator.update(state: state.wrappedValue)
context.coordinator.update(layout: layout, behavior: behavior)
},
transaction: context.transaction
)
}
}
/// A proxy for exposing and controlling a client coordinator object.
///
/// This proxy is introduced to make the implementation more extensible, rather than directly treating a Coordinator
/// with a lifecycle that spans across FloatingPanelView as a FloatingPanelCoordinator. This object was created to
/// control `FloatingPanelView/state` binding property.
@available(iOS 14, *)
class FloatingPanelCoordinatorProxy {
private let origin: any FloatingPanelCoordinator
private var stateBinding: Binding<FloatingPanelState?>
private var subscriptions: Set<AnyCancellable> = Set()
var proxy: FloatingPanelProxy { origin.proxy }
var controller: FloatingPanelController { origin.controller }
init(
coordinator: any FloatingPanelCoordinator,
state: Binding<FloatingPanelState?>
) {
self.origin = coordinator
self.stateBinding = state
}
deinit {
for subscription in subscriptions {
subscription.cancel()
}
}
func setupFloatingPanel<Main: View, Content: View>(
mainHostingController: UIHostingController<Main>,
contentHostingController: UIHostingController<Content>
) {
origin.setupFloatingPanel(
mainHostingController: mainHostingController,
contentHostingController: contentHostingController
)
}
func onUpdate<Representable>(
context: UIViewControllerRepresentableContext<Representable>
) where Representable: UIViewControllerRepresentable {
origin.onUpdate(context: context)
}
}
@available(iOS 14, *)
extension FloatingPanelCoordinatorProxy {
// MARK: - Layout and behavior updates
/// Update layout and behavior objects for the specified floating panel.
func update(
layout: (any FloatingPanelLayout)?,
behavior: (any FloatingPanelBehavior)?
) {
let shouldInvalidateLayout = controller.layout !== layout
if let layout = layout {
controller.layout = layout
} else {
controller.layout = FloatingPanelBottomLayout()
}
if shouldInvalidateLayout {
controller.invalidateLayout()
}
if let behavior = behavior {
controller.behavior = behavior
} else {
controller.behavior = FloatingPanelDefaultBehavior()
}
}
}
@available(iOS 14, *)
extension FloatingPanelCoordinatorProxy {
// MARK: - State updates
// Update the state of FloatingPanelController
func update(state: FloatingPanelState?) {
guard let state = state else { return }
controller.move(to: state, animated: false)
}
/// Start observing ``FloatingPanelController/state`` through the `Core` object.
func observeStateChanges() {
controller.floatingPanel.statePublisher?
.sink { [weak self] state in
guard let self = self else { return }
// Needs to update the state binding value on the next run loop cycle to avoid this error.
// > Modifying state during view update, this will cause undefined behavior.
Task { @MainActor in
self.stateBinding.wrappedValue = state
}
}.store(in: &subscriptions)
}
}
@available(iOS 14, *)
extension FloatingPanelCoordinatorProxy {
// MARK: - Environment updates
/// Applies animatable environment value changes.
func apply(animatableChanges: @escaping () -> Void, transaction: Transaction) {
/// Returns the default animator object for compatibility with iOS 17 and earlier.
func animateUsingDefaultAnimator(changes: @escaping () -> Void) {
let animator = controller.makeDefaultAnimator()
animator.addAnimations(changes)
animator.startAnimation()
}
if let animation = transaction.animation, transaction.disablesAnimations == false {
#if compiler(>=6.0)
if #available(iOS 18, *) {
UIView.animate(animation) {
animatableChanges()
}
} else {
animateUsingDefaultAnimator {
animatableChanges()
}
}
#else
animateUsingDefaultAnimator {
animatableChanges()
}
#endif
} else {
animatableChanges()
}
}
}
#endif