I've found a very strange behaviour, sheet or fullScreenCover does not release objects that were passed to its item: parameter.
It works well and memory is releasing on iOS 16 built with both Xcode 14 or 15. (simulators, devices)
Memory is leaking and NOT releasing on iOS 17 built with Xcode 15. (simulators, device 17.0.2)
Did anybody find any info from Apple about this?
UPDATE: Apple has fixed this bug on iOS 17.2. So, we have this memory leak only on iOS 17.0...17.1.
struct CoordinatorView: View {
@State var sheetVM: SheetVM?
var body: some View {
Button {
sheetVM = .init(dataGetter: {
/// External injection needed here. This is just a simplified example
try await Task.sleep(nanoseconds: 1_000_000_000)
return "New title"
})
} label: {
Text("Navigate")
}
.sheet(item: $sheetVM) { sheetVM in
SheetView(viewModel: sheetVM)
}
}
}
final class SheetVM: ObservableObject, Identifiable {
@Published var title: String = "Title"
private let dataGetter: () async throws -> String
init(dataGetter: @escaping () async throws -> String) {
self.dataGetter = dataGetter
print("SheetVM init")
Task { [refresh] in
await refresh()
}
}
@MainActor
@Sendable
private func refresh() async {
do {
title = try await dataGetter()
} catch {
print("Failed to get data")
}
}
deinit { print("... SheetVM deinit") }
}
struct SheetView: View {
@ObservedObject var viewModel: SheetVM
var body: some View {
Text("\(viewModel.title)")
}
}
struct SheetView_Previews: PreviewProvider {
static var previews: some View {
CoordinatorView()
}
}
Apple engineers have recognised that sheet memory leak is a bug and known issue (r. 115856582) on iOS 17 affecting sheet and fullScreenCover presentation. They proposed the workaround where you can use bridge to UIKit to create your own presentation controllers above your SwiftUI content (preventing the memory retention issue).
https://developer.apple.com/forums/thread/737967?answerId=767599022#767599022
Here is the full code snippet (though it does not support Binding objects, so you can't use something like .sheet(item: $object))
import SwiftUI
enum SheetPresenterStyle {
case sheet
case popover
case fullScreenCover
case detents([UISheetPresentationController.Detent])
}
class SheetWrapperController: UIViewController {
let style: SheetPresenterStyle
init(style: SheetPresenterStyle) {
self.style = style
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
if case let (.detents(detents)) = style, let sheetController = self.presentationController as? UISheetPresentationController {
sheetController.detents = detents
sheetController.prefersGrabberVisible = true
}
}
}
struct SheetPresenter<Content>: UIViewRepresentable where Content: View {
let label: String
let content: () -> Content
let style: SheetPresenterStyle
init(_ label: String, style: SheetPresenterStyle, @ViewBuilder content: @escaping () -> Content) {
self.label = label
self.content = content
self.style = style
}
func makeUIView(context: UIViewRepresentableContext<SheetPresenter>) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(label, for: .normal)
let action = UIAction { _ in
let hostingController = UIHostingController(rootView: content())
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
let viewController = SheetWrapperController(style: style)
switch style {
case .sheet:
viewController.modalPresentationStyle = .automatic
case .popover:
viewController.modalPresentationStyle = .popover
viewController.popoverPresentationController?.sourceView = button
case .fullScreenCover:
viewController.modalPresentationStyle = .fullScreen
case .detents:
viewController.modalPresentationStyle = .automatic
}
viewController.addChild(hostingController)
viewController.view.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: viewController.view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: viewController.view.bottomAnchor),
])
hostingController.didMove(toParent: viewController)
if let rootVC = button.window?.rootViewController {
rootVC.present(viewController, animated: true)
}
}
button.addAction(action, for: .touchUpInside)
return button
}
func updateUIView(_ uiView: UIButton, context: Context) {}
}
typealias ContentView = ContentViewB
struct ContentViewA: View {
@State private var showSheet = false
@State private var showPopover = false
@State private var showFullScreenCover = false
var body: some View {
VStack {
Button("Present Sheet") { showSheet.toggle() }
Button("Present Popover") { showPopover.toggle() }
Button("Present Full Screen Cover") { showFullScreenCover.toggle() }
Text("First")
.sheet(isPresented: $showSheet) {
SheetView()
}
.popover(isPresented: $showPopover) {
PopoverView()
}
.fullScreenCover(isPresented: $showFullScreenCover) {
FullScreenCoverView()
}
}
}
}
struct ContentViewB: View {
var body: some View {
VStack {
SheetPresenter("Present Sheet", style: .sheet) {
SheetView()
}
SheetPresenter("Present Popover", style: .popover) {
PopoverView()
}
SheetPresenter("Present Full Screen Cover", style: .fullScreenCover) {
FullScreenCoverView()
}
SheetPresenter("Present Presentation Detents", style: .detents([.medium(), .large()])) {
PresentationDetentsView()
}
Text("First")
}
}
}
struct SheetView: View {
private let log = LifecycleLogger(name: "SheetView")
@Environment(\.dismiss) var dismiss
var body: some View {
Text("SheetView")
Button("Back") { dismiss() }
}
}
struct PopoverView: View {
private let log = LifecycleLogger(name: "PopoverView")
@Environment(\.dismiss) var dismiss
var body: some View {
Text("PopoverView")
Button("Back") { dismiss() }
}
}
struct FullScreenCoverView: View {
private let log = LifecycleLogger(name: "FullScreenCoverView")
@Environment(\.dismiss) var dismiss
var body: some View {
Text("FullScreenCoverView")
Button("Back") { dismiss() }
}
}
struct PresentationDetentsView: View {
private let log = LifecycleLogger(name: "PresentationDetentsView")
@Environment(\.dismiss) var dismiss
var body: some View {
Text("PresentationDetentsView")
Button("Back") { dismiss() }
}
}
class LifecycleLogger {
let name: String
init(name: String) {
self.name = name
print("\(name).init")
}
deinit {
print("\(name).deinit")
}
}
This is valid code and I have replicated your observations - deinit is not called, and in Instruments the allocated memory grows with each invocation of the sheet.
There is no SwiftUI alternative syntax which would guarantee the deinit to be called, so I think you're unlikely to get any further help from StackOverflow users on this topic. Your best bet would be to file a bug with Apple, either via the Apple Feedback app on your Mac or at feedbackassistant.apple.com. (see Bug Reporting).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With