Brendan McNamara
Brendan McNamara

Reputation: 15

Setting up Lifecycle Callbacks on SwiftUI Views with modifiers

I am somewhat new to SwiftUI and still figuring out how to setup more complex applications and views.

I am building an application that has a TikTok-like vertical page scroll, where users can swipe up and down to get to the next piece of content. Each item in the vertical scroll controls its own content. It can be videos, social media posts, PSA's, etc ... Here are the relevant parts of the code:

/// The feed that shows the vertical reel of items.
struct FeedView: View {
  // Currently tracking this variable based on the scroll position in the
  // scroll view. It will tell me which page we are currently looking at.
  @State var page: Int

  var body: some View {
    GeometryView { geometry in
      ScrollView(.vertical) {
        // In here, we are rendering a bunch of feed items to display.
      }
    }
  }
}

struct TextAndImageFeedItemView: View {
  var body: some View { /* ... */ }
}

struct VideoFeedItemView: View {
  var body: some View { /* ... */ }
}

struct AdPromotionFeedItemView: View {
  var body: some View { /* ... */ }
}

I am rendering a bunch of these feed item views when they are off-screen. My question is: how do I notify my feed item views that they are currently the view being presented? They will appear while still offscreen but I want them to know they are currently the one being presented. Ideally, I'd want to do it in the following way:

struct VideoFeedItemView: View {
  var body: some View {
    VideoPlayerView(player: myVideoPlayer)
      .onFeedItemPresented {
        // play the video
      }
      .onFeedItemDismissed {
        // stop the video
      }
  }
}

Thanks in advance for the help!

I tried searching for other solutions and reviewed Apple's docs on custom modifiers. But haven't been able to find any good examples of notifying subviews via callback.

Upvotes: 1

Views: 388

Answers (1)

Sweeper
Sweeper

Reputation: 270980

Environment is how you pass things from parent views to child views. Child views just has to read the environment, and use onChange to detect changes in it.

Let's first make our own EnvironmentKey for whether a view is presented.

enum PresentationState: Hashable {
    case presented
    case dismissed
}

struct PresentationStateKey: EnvironmentKey {
    static var defaultValue = PresentationState.dismissed
}

extension EnvironmentValues {
    var presentationState: PresentationState {
        get { self[PresentationStateKey.self] }
        set { self[PresentationStateKey.self] = newValue }
    }
}

Then we can write a modifier that reads the Environment and also detects changes.

struct PresentationStateChangeModifier: ViewModifier {
    // callback will fire when state changes to detectState
    let detectState: PresentationState
    
    let callback: () -> Void
    
    @Environment(\.presentationState) var state
    
    func body(content: Content) -> some View {
        content.onChange(of: state) { _, newValue in
            if newValue == detectState {
                callback()
            }
        }
    }
}

extension View {
    func onPresented(action: @escaping () -> Void) -> some View {
        modifier(PresentationStateChangeModifier(detectState: .presented, callback: action))
    }
    
    func onDismissed(action: @escaping () -> Void) -> some View {
        modifier(PresentationStateChangeModifier(detectState: .dismissed, callback: action))
    }
}

Here is a usage example with a paging scroll view:

struct ContentView: View {
    @State var currentView = -1
    
    
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0..<10) { i in
                    SomeView(i: i)
                        .environment(\.presentationState, i == currentView ? .presented : .dismissed)
                        .id(i)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
        .scrollPosition(id: Binding($currentView), anchor: .center)
        .onAppear {
            // to trigger onPresented for the initially displayed view
            currentView = 0
            // you might not need this depending on how your feed is set up
        }
    }
}

struct SomeView: View {
    
    let i: Int
    
    var body: some View {
        Text("\(i)")
            .containerRelativeFrame([.horizontal, .vertical])
            .background(.yellow)
            .onPresented {
                print("Presented \(i)")
            }
            .onDismissed {
                print("Dismissed \(i)")
            }
    }
}

Upvotes: 1

Related Questions