31

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")
    }
}
codezero11
  • 427
  • 1
  • 5
  • 10
  • 1
    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. – Asperi Mar 09 '20 at 07:46
  • 2
    @Asperi 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. – Big_Chair Nov 13 '21 at 10:08

3 Answers3

17

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.

Frankenstein
  • 15,732
  • 4
  • 22
  • 47
  • Thanks but is there a way that I can do this inside a function, like `private func () { if (TestView.isDisplayed()) {do something}}`? – codezero11 Mar 09 '20 at 07:18
  • You need to specify that your requirement is to check if the view is displayed inside an external function. – Frankenstein Mar 09 '20 at 07:25
  • I've modified my answer according to your requirement. – Frankenstein Mar 09 '20 at 07:26
  • Thanks I have solved it. I will mark your answer as correct. :) – codezero11 Mar 09 '20 at 08:41
  • 45
    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()` – Oleg G. Nov 18 '20 at 18:52
  • https://stackoverflow.com/questions/1536923/determine-if-uiview-is-visible-to-the-user although this is not in swiftui. it is the sort of behavior expected. – Oleg G. Nov 18 '20 at 18:53
  • 2
    This isn't a great solution. If a fullscreencover happens and the underlying view disappears, onDisappear will not fire. – FontFamily Jun 01 '21 at 20:49
  • 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 tabs – Benzy Jun 23 '21 at 15:58
  • 1
    OnAppear is not guaranteed to be called when a view becomes visible. – Karlth Jun 30 '22 at 10:05
  • As mentioned in the answer it has many edge cases, if apple releases a better "SwiftUI solution" I'll surely update this. – Frankenstein Dec 12 '22 at 07:24
16

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.

Benjohn
  • 13,228
  • 9
  • 65
  • 127
  • 1
    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. – orion Jun 11 '23 at 22:41
  • 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? – Benjohn Jun 13 '23 at 08:14
  • 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))`. – joeshonm Jun 15 '23 at 02:04
13

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
            }
Seshu Vadlapudi
  • 139
  • 1
  • 4