codezero11
codezero11

Reputation: 527

How to check if a view is displayed on the screen? (Swift 5 and SwiftUI)

I have a view like below. I want to find out if it is the view which is displayed on the screen. Is there a function to achieve this?

struct TestView: View {
    var body: some View {
        Text("Test View")
    }
}

Upvotes: 39

Views: 36594

Answers (6)

Abdullah Ayan
Abdullah Ayan

Reputation: 1

You can use this as a view modifier:

import SwiftUI

struct IsVisibleModifier: ViewModifier {
    @Binding var isVisible: Bool
    
    func body(content: Content) -> some View {
        content
            .onAppear {
                isVisible = true
            }
            .onDisappear {
                isVisible = false
            }
    }
}

extension View {
    func isVisible(_ isVisible: Binding<Bool>) -> some View {
        self.modifier(IsVisibleModifier(isVisible: isVisible))
    }
}

Upvotes: 0

B.T. Kreosote
B.T. Kreosote

Reputation: 21

I think @Benjohn's view extension is the way to go but ran into the concern he noted with using UIScreen to determine view visibility. I was trying to determine when a view became visible or not visible in a ScrollView somewhere in the middle of the screen. Specifically, the view I was trying to control was a MapKit view that consumes a staggering amount of memory when created. I know MapKit is complex, but I'd sure like to know how it's possible to consume so much memory even for displaying an empty map. In any case, I modified Ben's code to use parent/child GeometryProxies to determine visibility. The usage is straightforward. You need a GeometryReader for the parent view, and you add this to the child view:

.onVisibilityChange(proxy: self.parentProxy) {isVisible in            
    debugPrint("View visibility changed to \(isVisible)")
}

The revised code is:

public extension View {
    
    func onVisibilityChange(proxy: GeometryProxy, perform action: @escaping (Bool)->Void)-> some View {
        modifier(BecomingVisible(parentProxy: proxy, action: action))
    }
}

private struct BecomingVisible: ViewModifier {
    var parentProxy: GeometryProxy
    var action: ((Bool)->Void)
    
    @State var isVisible: Bool = false
    
    func checkVisible(proxy: GeometryProxy) {
        
        let parentFrame = self.parentProxy.frame(in: .global)
        let childFrame = proxy.frame(in: .global)
        
        let isVisibleNow = parentFrame.intersects(childFrame)
        
        if (self.isVisible != isVisibleNow) {
            self.isVisible = isVisibleNow
            self.action(isVisibleNow)
        }
    }

    func body(content: Content) -> some View {
        content.overlay {
            GeometryReader { proxy in
                Color.clear
                    .onAppear() {
                        self.checkVisible(proxy: proxy)
                    }
                    .onChange(of: proxy.frame(in: .global)) {
                        self.checkVisible(proxy: proxy)
                    }
            }
        }
    }
}

Upvotes: 2

Plato
Plato

Reputation: 691

If you're using UIKit and SceneDelegate along with SwiftUI, you can solve this with a combination of UIHostingViewController and a "visibleViewController" property like the one below. This solution worked the best for my use case.

Basically, just check if SceneDelegate's topmost view controller is the same as the SwiftUI View's hosting controller.

    static var visibleViewController: UIViewController? {
        get {
            guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
            let delegate = windowScene.delegate as? SceneDelegate, let window = delegate.window else { return nil }
            guard let rootVC = window.rootViewController else { return nil }
            return getVisibleViewController(rootVC)
        }
    }
    
    static private func getVisibleViewController(_ rootViewController: UIViewController) -> UIViewController? {
        if let presentedViewController = rootViewController.presentedViewController {
            return getVisibleViewController(presentedViewController)
        }

        if let navigationController = rootViewController as? UINavigationController {
            return navigationController.visibleViewController
        }

        if let tabBarController = rootViewController as? UITabBarController {
            if let selectedTabVC = tabBarController.selectedViewController {
                return getVisibleViewController(selectedTabVC)
            }
            return tabBarController
        }

        return rootViewController
    }

Then in your SwiftUI View, you can add this Boolean:

    var isViewDisplayed: Bool {
        if let visibleVc = SceneDelegate.visibleViewController {
            return visibleVc.isKind(of: CustomHostingViewController.self)
        } else {
            return false
        }
    }

Upvotes: 0

Benjohn
Benjohn

Reputation: 13887

As mentioned by Oleg, depending on your use case, a possible issue with onAppear is its action will be performed as soon as the View is in a view hierarchy, regardless of whether the view is potentially visible to the user.

My use case is wanting to lazy load content when a view actually becomes visible. I didn't want to rely on the view being encapsulated in a LazyHStack or similar.

To achieve this I've added an extension onBecomingVisible to View that has the same kind of API as onAppear, but only calls the action when (and only if) the view first intersects the screen's visible bounds. The action is never subsequently called.

public extension View {
    
    func onBecomingVisible(perform action: @escaping () -> Void) -> some View {
        modifier(BecomingVisible(action: action))
    }
}

private struct BecomingVisible: ViewModifier {
    
    @State var action: (() -> Void)?

    func body(content: Content) -> some View {
        content.overlay {
            GeometryReader { proxy in
                Color.clear
                    .preference(
                        key: VisibleKey.self,
                        // See discussion!
                        value: UIScreen.main.bounds.intersects(proxy.frame(in: .global))
                    )
                    .onPreferenceChange(VisibleKey.self) { isVisible in
                        guard isVisible, let action else { return }
                        action()
                        action = nil
                    }
            }
        }
    }

    struct VisibleKey: PreferenceKey {
        static var defaultValue: Bool = false
        static func reduce(value: inout Bool, nextValue: () -> Bool) { }
    }
}

Discussion

I'm not thrilled by using UIScreen.main.bounds in the code! Perhaps a geometry proxy could be used for this instead, or some @Environment value – I've not thought about this yet though.

Upvotes: 26

Frankenstein
Frankenstein

Reputation: 16371

You could use onAppear on any kind of view that conforms to View protocol.

struct TestView: View {
    @State var isViewDisplayed = false
    var body: some View {
        Text("Test View")
        .onAppear {
            self.isViewDisplayed = true
        }
        .onDisappear {
            self.isViewDisplayed = false
        }
    }

    func someFunction() {
        if isViewDisplayed {
            print("View is displayed.")
        } else {
            print("View is not displayed.")
        }
    }
}

PS: Although this solution covers most cases, it has many edge cases that has not been covered. I'll be updating this answer when Apple releases a better solution for this requirement.

Upvotes: 13

Seshu Vadlapudi
Seshu Vadlapudi

Reputation: 159

You can check the position of view in global scope using GeometryReader and GeometryProxy.

        struct CustomButton: View {
            var body: some View {
                GeometryReader { geometry in
                    VStack {
                        Button(action: {
                        }) {
                            Text("Custom Button")
                                .font(.body)
                                .fontWeight(.bold)
                                .foregroundColor(Color.white)
                        }
                        .background(Color.blue)
                    }.navigationBarItems(trailing: self.isButtonHidden(geometry) ?
                            HStack {
                                Button(action: {
                                }) {
                                    Text("Custom Button")
                                } : nil)
                }
            }

            private func isButtonHidden(_ geometry: GeometryProxy) -> Bool {
    // Alternatively, you can also check for geometry.frame(in:.global).origin.y if you know the button height.
                if geometry.frame(in: .global).maxY <= 0 {
                    return true
                }
                return false
            }

Upvotes: 15

Related Questions