Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI Sheet never releases object from memory

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()
    }
}
like image 283
bodich Avatar asked Dec 09 '25 12:12

bodich


2 Answers

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")
    }
}
like image 55
bodich Avatar answered Dec 11 '25 09:12

bodich


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).

like image 38
Scott Matthewman Avatar answered Dec 11 '25 07:12

Scott Matthewman



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!