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

5

36

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")
    }
}
Nasser answered 9/3, 2020 at 6:59 Comment(2)
You want transfer proactive behaviour to reactive nature of SwiftUI. In SwiftUI concept some state (or in view model or in @State) determines whether view is visible or not. So having such state you don't need to ask view you use it directly.Settle
@Settle It's not about setting the visibility, it's to check whether the view is currently inside the view port and if the user can see it.Bedazzle
N
13

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.

Nalda answered 9/3, 2020 at 7:5 Comment(10)
Thanks but is there a way that I can do this inside a function, like private func () { if (TestView.isDisplayed()) {do something}}?Nasser
You need to specify that your requirement is to check if the view is displayed inside an external function.Nalda
I've modified my answer according to your requirement.Nalda
Thanks I have solved it. I will mark your answer as correct. :)Nasser
This is not really a displayed on screen. It will be call even if it doesn't show on the screen. Just by loading the view it will call .onAppear()Nelda
#1537423 although this is not in swiftui. it is the sort of behavior expected.Nelda
This isn't a great solution. If a fullscreencover happens and the underlying view disappears, onDisappear will not fire.Anaheim
This will not work on iOS14 if you have the view inside a tabview / navigationview. There is a bug that calls onAppear immediately after onDisappear when you switch between tabsEskew
OnAppear is not guaranteed to be called when a view becomes visible.Icono
As mentioned in the answer it has many edge cases, if apple releases a better "SwiftUI solution" I'll surely update this.Nalda
T
24

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.

Tree answered 19/7, 2022 at 13:41 Comment(7)
I've seen some other SO answers where people place a reference to a scene's window into the environment. That's better than UIScreen.main because iPad apps can be non-full screen, and even have multiple screens.Faux
👍 Thanks @orion. I think adding an optional in: parameter to onBecomingVisible(perform:) could work? That would then let a caller specify it from what they know (eg, an environment value as you suggest, but also other approaches). … I guess it's possible it would need to be a reference to state instead of a fixed value though, given windows can themself be changing shape?Tree
I used this solution to track if a view is visible on screen from a ScrollView. I wrapped the ScrollView in GeometryReader and passed the proxy and coordinateSpace of the ScrollView into onBecomingVisible. I used the frame of the View's proxy according to the named coordinateSpace like this itemProxy.frame(in: .named(coordinateSpace)).Pamalapamela
Just tried this @Tree . It sorta works, but sadly the action gets triggered when the view is partially shown, not when it is actually fully visible. The second a single pixel of the View is shown, it runs the closure. If someone happens to figure out some way to make it so it waits until the entire View is shown on screen, that would be great. Thank you either way, great approach!Swordplay
@Swordplay :-) Ah – yes – it is "by design" that the action fires as soon as the view becomes at all visible. If you want to change this behaviour then you would want to modify the line UIScreen.main.bounds.intersects(proxy.frame(in: .global)). You'd need to have a function to replace intersects that would instead reflect fullyContains. Be careful if you take this approach that the main screen can contain the displayed view. If the view is larger than the screen then this will never happen!Tree
Gotcha. Thanks for the insight !:)Swordplay
@Pamalapamela can you show us the code you used for that?Mahala
R
15

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
            }
Recreant answered 27/5, 2020 at 14:33 Comment(1)
This code doesn't work, maybe some pieces are missing...Server
N
13

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.

Nalda answered 9/3, 2020 at 7:5 Comment(10)
Thanks but is there a way that I can do this inside a function, like private func () { if (TestView.isDisplayed()) {do something}}?Nasser
You need to specify that your requirement is to check if the view is displayed inside an external function.Nalda
I've modified my answer according to your requirement.Nalda
Thanks I have solved it. I will mark your answer as correct. :)Nasser
This is not really a displayed on screen. It will be call even if it doesn't show on the screen. Just by loading the view it will call .onAppear()Nelda
#1537423 although this is not in swiftui. it is the sort of behavior expected.Nelda
This isn't a great solution. If a fullscreencover happens and the underlying view disappears, onDisappear will not fire.Anaheim
This will not work on iOS14 if you have the view inside a tabview / navigationview. There is a bug that calls onAppear immediately after onDisappear when you switch between tabsEskew
OnAppear is not guaranteed to be called when a view becomes visible.Icono
As mentioned in the answer it has many edge cases, if apple releases a better "SwiftUI solution" I'll surely update this.Nalda
A
1

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)
                    }
            }
        }
    }
}
Allative answered 7/6 at 4:49 Comment(0)
C
0

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
        }
    }
Chemo answered 21/9, 2023 at 16:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.