Search code examples
iosswiftswiftui

Center SwiftUI view in top-level view


I am creating a loading indicator in SwiftUI that should always be centered in the top-level view of the view hierarchy (i.e centered in the whole screen in a fullscreen app). This would be easy in UIKit, but SwiftUI centres views relative to their parent view only and I am not able to get the positions of the parent views of the parent view.

Sadly my app is not fully SwiftUI based, so I cannot easily set properties on my root views that I could then access in my loading view - I need this view to be centered regardless of what the view hierarchy looks like (mixed UIKit - SwiftUI parent views). This is why answers like SwiftUI set position to centre of different view don't work for my use case, since in that example, you need to modify the view in which you want to centre your child view.

I have tried playing around with the .offset and .position functions of View, however, I couldn't get the correct inputs to always dynamically centre my loadingView regardless of screen size or regardless of what part of the whole screen rootView takes up.

Please find a minimal reproducible example of the problem below:

/// Loading view that should always be centered in the whole screen on the XY axis and should be the top view in the Z axis
struct CenteredLoadingView<RootView: View>: View {
    private let rootView: RootView

    init(rootView: RootView) {
        self.rootView = rootView
    }

    var body: some View {
        ZStack {
            rootView
                loadingView
        }
             // Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
            .zIndex(.infinity)
    }

    private var loadingView: some View {
        VStack {
            Color.white
                .frame(width: 48, height: 72)
            Text("Loading")
                .foregroundColor(.white)
        }
            .frame(width: 142, height: 142)
            .background(Color.primary.opacity(0.7))
            .cornerRadius(10)
    }
}

View above which the loading view should be displayed:

struct CenterView: View {
    var body: some View {
        return VStack {
            Color.gray
            HStack {
                CenteredLoadingView(rootView: list)
                otherList
            }
        }
    }

    var list: some View {
        List {
            ForEach(1..<6) {
                Text($0.description)
            }
        }
    }

    var otherList: some View {
        List {
            ForEach(6..<11) {
                Text($0.description)
            }
        }
    }
}

This is what the result looks like: current (incorrect) UI

This is how the UI should look like: desired UI

I have tried modifying the body of CenteredLoadingView using a GeometryReader and .frame(in: .global) to get the global screen size, but what I've achieved is that now my loadingView is not visible at all.

var body: some View {
    GeometryReader<AnyView> { geo in
        let screen = geo.frame(in: .global)
        let stack = ZStack {
            self.rootView
            self.loadingView
                .position(x: screen.midX, y: screen.midY)
                // Offset doesn't work either
                //.offset(x: -screen.origin.x, y: -screen.origin.y)
        }
             // Ensure that `AnimatedLoadingView` is displayed above all other views, whose `zIndex` would be higher than `rootView`'s by default
            .zIndex(.infinity)
        return AnyView(stack)
    }
}

Solution

  • Here is a demo of possible approach. The idea is to use injected UIView to access UIWindow and then show loading view as a top view of window's root viewcontroller view.

    Tested with Xcode 12 / iOS 14 (but SwiftUI 1.0 compatible)

    enter image description here

    Note: animations, effects, etc. are possible but are out scope for simplicity

    struct CenteredLoadingView<RootView: View>: View {
        private let rootView: RootView
        @Binding var isActive: Bool
    
        init(rootView: RootView, isActive: Binding<Bool>) {
            self.rootView = rootView
            self._isActive = isActive
        }
    
        var body: some View {
            rootView
                .background(Activator(showLoading: $isActive))
        }
    
        struct Activator: UIViewRepresentable {
            @Binding var showLoading: Bool
            @State private var myWindow: UIWindow? = nil
    
            func makeUIView(context: Context) -> UIView {
                let view = UIView()
                DispatchQueue.main.async {
                    self.myWindow = view.window
                }
                return view
            }
    
            func updateUIView(_ uiView: UIView, context: Context) {
                guard let holder = myWindow?.rootViewController?.view else { return }
    
                if showLoading && context.coordinator.controller == nil {
                    context.coordinator.controller = UIHostingController(rootView: loadingView)
    
                    let view = context.coordinator.controller!.view
                    view?.backgroundColor = UIColor.black.withAlphaComponent(0.8)
                    view?.translatesAutoresizingMaskIntoConstraints = false
                    holder.addSubview(view!)
                    holder.isUserInteractionEnabled = false
    
                    view?.leadingAnchor.constraint(equalTo: holder.leadingAnchor).isActive = true
                    view?.trailingAnchor.constraint(equalTo: holder.trailingAnchor).isActive = true
                    view?.topAnchor.constraint(equalTo: holder.topAnchor).isActive = true
                    view?.bottomAnchor.constraint(equalTo: holder.bottomAnchor).isActive = true
                } else if !showLoading {
                    context.coordinator.controller?.view.removeFromSuperview()
                    context.coordinator.controller = nil
                    holder.isUserInteractionEnabled = true
                }
            }
    
            func makeCoordinator() -> Coordinator {
                Coordinator()
            }
    
            class Coordinator {
                var controller: UIViewController? = nil
            }
    
            private var loadingView: some View {
                VStack {
                    Color.white
                        .frame(width: 48, height: 72)
                    Text("Loading")
                        .foregroundColor(.white)
                }
                    .frame(width: 142, height: 142)
                    .background(Color.primary.opacity(0.7))
                    .cornerRadius(10)
            }
        }
    }
    
    struct CenterView: View {
        @State private var isLoading = false
        var body: some View {
            return VStack {
                Color.gray
                HStack {
                    CenteredLoadingView(rootView: list, isActive: $isLoading)
                    otherList
                }
                Button("Demo", action: load)
            }
            .onAppear(perform: load)
        }
    
        func load() {
            self.isLoading = true
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.isLoading = false
            }
        }
    
        var list: some View {
            List {
                ForEach(1..<6) {
                    Text($0.description)
                }
            }
        }
    
        var otherList: some View {
            List {
                ForEach(6..<11) {
                    Text($0.description)
                }
            }
        }
    }