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.

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

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 { }

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.

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

SwiftUI View leaks in iOS 17
 
 
Q