SwiftUI View leaks in iOS 17

Seems View and its members do not deallocate after a presentation:

@main
struct ZooskNewApp: SwiftUI.App {
    @State var show = false

    var body: some Scene {
        WindowGroup {
            VStack {
                Button("Present") { show = true }
                Text("First")
                    .fullScreenCover(isPresented: $show) {
                        CheckSecond { show = false }
                    }
            }
        }
    }
}

struct CheckSecond: View {
    private let log = LogDeinit()
    var action: () -> Void
    
    var body: some View {
        Text("Second")
        Button("Back"){ action() }
    }
}

class LogDeinit {
    init(name: String) {
        print("init")
    }
    
    deinit {
        print("deinit")
    }
}

Showing 'CheckSecond' back and forth causes 'init' in the console, but no 'deinit'. Memory map shows that 'LogDeinit' objects are referenced by single SwiftUI objects without any additional connection. In iOS 16 works correctly - 'deinit' is printed on each dismiss. Looks like a real leak.

Post not yet marked as solved Up vote post of Down vote post of
3.8k views

Replies

Hi DmitryKurkin,

This appears to be a known issue (r. 115856582) on iOS 17 affecting sheet and fullScreenCover presentation. As a workaround, you can use bridge to UIKit to create your own presentation controllers above your SwiftUI content (preventing the memory retention issue). Please see the following code snippet as a guide:

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")
    }
}

Cheers,

Paris

  • Unfortunately, we can't use bindings in this workaround. The plenty of sheets usage looks like this .sheet(item: $object). Hopefully it will be fixed in some iOS/SwiftUI version in some nearest future. Because we have huge memory leaks, awful workarounds to fix them and waste of time. Would be nice if Apple can provide a real workaround which can fully replace native sheet and fullScreenCover by overloading that View methods, but I did not find that yet on the internet(((

  • I researched how to solve the problem and found this code. However, this introduces a new memory leak. Please add this.

    static func dismantleUIView(_ button: UIButton, coordinator: Coordinator) {
        button.removeTarget(nil, action: nil, for: .allEvents)
     }
    
    
Add a Comment

It seems that views become not lazy in fullScreenCover. Try to use this: struct DeferView<Content: View>: View { let content: () -> Content

init(@ViewBuilder _ content: @escaping () -> Content) {
    self.content = content
}

var body: some View {
    content()
}

}

DeferView {   
   your content   
}

Seems View and its members do not deallocate after a presentation

Modal views usually have a separate hierarchy... So, I believe this should work:


//  Created by Dmitry Novosyolov on 31/10/2023.
//

import SwiftUI

struct ContentView: View {
    @State
    private var someModalVM: SomeModalViewModel? = nil
    var body: some View {
        Button("present") {
            someModalVM = .init(modalValue: 10)
        }
        .fullScreenCover(
            item: $someModalVM,
            onDismiss: {
                print("Model Class value: \(someModalVM?.modalValue as Any)")
            }
        ) {
            ModalView(someModalVM: $0)
        }
    }
}

struct ModalView: View {
    @Environment(\.dismiss)
    private var dismiss
    let someModalVM: SomeModalViewModel
    var body: some View {
        Button("dismiss", action: dismiss.callAsFunction)
        .tint(.white)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background {
            Color.blue.ignoresSafeArea()
        }
    }
}

#Preview {
    ContentView()
}

@Observable
final class SomeModalViewModel {
    let modalValue: Int
    init(modalValue: Int) {
        self.modalValue = modalValue
        print("Model Class value: \(self.modalValue)")
    }
}

extension SomeModalViewModel: Identifiable { }

  • The problem is not if the @State property does not become nil, it does. The problem is your SomeModalViewModel will never deinitialise. Just add this to SomeModalViewModel and you will see the problem did not disappear. deinit { print("Model Class deinit") }

  • The problem is that Apple forgot to deallocate the object when closing the modal view. In this case, my code clearly indicates, that the object must be deallocated when the modal presentation is dismissed. 🙂

    If you have a better solution to this problem, I would be happy to explore it.

  • Not sure what do you mean by a better solution. Your code is not a solution, the object is not being deallocated when using it.

Any updates on this? We're seeing the same bug and in our case, it could easily lead to crashes.

My solution without using UIKit Presentation style: https://gist.github.com/pookjw/093dfbe43714bb0ceec2e4df1d5a9499

Caution: It's super unsafe.

Post not yet marked as solved Up vote reply of pook Down vote reply of pook

The issue seems to be resolved on iOS 17.2! See this thread on Stack Overflow.