98

Trying to load an image after the view loads, the model object driving the view (see MovieDetail below) has a urlString. Because a SwiftUI View element has no life cycle methods (and there's not a view controller driving things) what is the best way to handle this?

The main issue I'm having is no matter which way I try to solve the problem (Binding an object or using a State variable), my View doesn't have the urlString until after it loads...

// movie object
struct Movie: Decodable, Identifiable {
    
    let id: String
    let title: String
    let year: String
    let type: String
    var posterUrl: String
    
    private enum CodingKeys: String, CodingKey {
        case id = "imdbID"
        case title = "Title"
        case year = "Year"
        case type = "Type"
        case posterUrl = "Poster"
    }
}
// root content list view that navigates to the detail view
struct ContentView : View {
    
    var movies: [Movie]
    
    var body: some View {
        NavigationView {
            List(movies) { movie in
                NavigationButton(destination: MovieDetail(movie: movie)) {
                    MovieRow(movie: movie)
                }
            }
            .navigationBarTitle(Text("Star Wars Movies"))
        }
    }
}
// detail view that needs to make the asynchronous call
struct MovieDetail : View {
    
    let movie: Movie
    @State var imageObject = BoundImageObject()
    
    var body: some View {
        HStack(alignment: .top) {
            VStack {
                Image(uiImage: imageObject.image)
                    .scaledToFit()
                
                Text(movie.title)
                    .font(.subheadline)
            }
        }
    }
}
starball
  • 20,030
  • 7
  • 43
  • 238
Kraig Wastlund
  • 1,094
  • 1
  • 8
  • 9

4 Answers4

112

We can achieve this using view modifier.

  1. Create ViewModifier:
struct ViewDidLoadModifier: ViewModifier {

    @State private var didLoad = false
    private let action: (() -> Void)?

    init(perform action: (() -> Void)? = nil) {
        self.action = action
    }

    func body(content: Content) -> some View {
        content.onAppear {
            if didLoad == false {
                didLoad = true
                action?()
            }
        }
    }

}
  1. Create View extension:
extension View {

    func onLoad(perform action: (() -> Void)? = nil) -> some View {
        modifier(ViewDidLoadModifier(perform: action))
    }

}
  1. Use like this:
struct SomeView: View {
    var body: some View {
        VStack {
            Text("HELLO!")
        }.onLoad {
            print("onLoad")
        }
    }
}
Murlakatam
  • 2,729
  • 2
  • 26
  • 20
  • why does this work? I would think the actionPerformed would be overwritten each time the view reloads? – Kyle Nov 18 '20 at 02:38
  • @Kyle I've updated my solution. Now it should work because `State` keeps it's value when view is changed. – Murlakatam Dec 16 '20 at 07:31
  • 4
    this answer needs to be upvoted more as it's the only one that really addresses the question – Jandro Rojas Jan 21 '21 at 21:04
  • 1
    This is brilliant!! Thank you!!! This should go into SwiftUI natively. – TruMan1 Jan 24 '21 at 22:18
  • This should be the selected answer – Just a coder Feb 05 '21 at 17:37
  • 4
    I agree that this is the best solution of those listed here, but it staill has the issue that the view must first appear in order to perform the onLoad function. On an iPad, you can't use this to select the first item in a list on the left navigation pane, because it is not initially visible. – Tony the Tech Mar 27 '21 at 18:10
  • @TonytheTech maybe in your case you can add somewhere in your view `Color.clear.onAppear {}` to do what you need? – Murlakatam Mar 28 '21 at 08:53
  • 2
    Nice answer! Though I would name this `ViewFirstAppearModifier`, to better transmit the intent and behaviour. – Cristik Feb 09 '22 at 07:03
71

I hope this is helpful. I found a blogpost that talks about doing stuff onAppear for a navigation view.

Idea would be that you bake your service into a BindableObject and subscribe to those updates in your view.

struct SearchView : View {
    @State private var query: String = "Swift"
    @EnvironmentObject var repoStore: ReposStore

    var body: some View {
        NavigationView {
            List {
                TextField($query, placeholder: Text("type something..."), onCommit: fetch)
                ForEach(repoStore.repos) { repo in
                    RepoRow(repo: repo)
                }
            }.navigationBarTitle(Text("Search"))
        }.onAppear(perform: fetch)
    }

    private func fetch() {
        repoStore.fetch(matching: query)
    }
}
import SwiftUI
import Combine

class ReposStore: BindableObject {
    var repos: [Repo] = [] {
        didSet {
            didChange.send(self)
        }
    }

    var didChange = PassthroughSubject<ReposStore, Never>()

    let service: GithubService
    init(service: GithubService) {
        self.service = service
    }

    func fetch(matching query: String) {
        service.search(matching: query) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let repos): self?.repos = repos
                case .failure: self?.repos = []
                }
            }
        }
    }
}

Credit to: Majid Jabrayilov

Community
  • 1
  • 1
andromedainiative
  • 4,414
  • 6
  • 22
  • 34
  • 23
    Correct me if i'm wrong but using `fetch` in `onAppear` causes network request on each time the view is appeared. (e.g in a TabView ). – Armin Oct 15 '20 at 08:11
  • I really hope there is a better way. I've seen the advice to use onAppear to select the first item in a list, for example. This strategy is flawed, because on an iPad, the left navigation panel is hidden by default. There needs to be a way to do some work on load of the view regardless of its being visible. – Tony the Tech Mar 27 '21 at 18:04
  • 18
    `onAppear` is more like `viewWillAppear` or `viewDidAppear`. The question was about `viewDidLoad`. – Charlie Fish Jul 26 '21 at 18:16
  • @Armin did you find any solution ? – Nirav Kotecha Aug 12 '21 at 06:55
  • This resolved my issue https://stackoverflow.com/a/68833844/11315821 – Saeed All Gharaee Feb 16 '22 at 16:57
  • This article gives a good illustration: [https://swiftontap.com/view/onappear(perform:)](https://swiftontap.com/view/onappear(perform:)). It's called whenever a page re-renders due to state changing, so it's not quite the same as the view appearing, or loading. It's somewhere in between. – James Toomey May 11 '22 at 22:02
22

Fully updated for Xcode 11.2, Swift 5.0

I think the viewDidLoad() just equal to implement in the body closure.
SwiftUI gives us equivalents to UIKit’s viewDidAppear() and viewDidDisappear() in the form of onAppear() and onDisappear(). You can attach any code to these two events that you want, and SwiftUI will execute them when they occur.

As an example, this creates two views that use onAppear() and onDisappear() to print messages, with a navigation link to move between the two:

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: DetailView()) {
                    Text("Hello World")
                }
            }
        }.onAppear {
            print("ContentView appeared!")
        }.onDisappear {
            print("ContentView disappeared!")
        }
    }
}

ref: https://www.hackingwithswift.com/quick-start/swiftui/how-to-respond-to-view-lifecycle-events-onappear-and-ondisappear

Wojciech Rutkowski
  • 11,299
  • 2
  • 18
  • 22
Zgpeace
  • 3,927
  • 33
  • 31
  • 17
    The question was about `viewDidLoad` not `viewDidAppear` or `viewWillAppear`. – Charlie Fish Jul 26 '21 at 18:15
  • @CharlieFish He said that onAppear is equivalent to viewDidLoad. I'm not sure about that, but why there isn't an official answer from Apple on this? – caravana_942 Jul 28 '21 at 21:03
  • This seems reasonable to me, but I'm not exactly sure if setting stuff in the creation of a view equals to `loadView` or `viewDidLoad`. Besides, SwiftUI View and UIKit View have quite different lifecycle (the former often gets recreated), so maybe there is no direct equivalent of `viewDidLoad` in a SwiftUI View. – yo1995 Oct 13 '21 at 05:01
  • This answer is not relevant though – ace_ventura Jun 16 '22 at 14:10
13

I'm using init() instead. I think onApear() is not an alternative to viewDidLoad(). Because onApear is called when your view is being appeared. Since your view can be appear multiple times it conflicts with viewDidLoad which is called once.

Imagine having a TabView. By swiping through pages onApear() is being called multiple times. However viewDidLoad() is called just once.

Saeed All Gharaee
  • 1,546
  • 1
  • 14
  • 27
  • 1
    One thing to keep in mind though: `init()` is called by its parent when the parent itself is loaded. And furthermore, the parent will init all its potential children, even though they never gets loaded. So it's not really `viewDidLoad()` either, thought it's only called once. – turingtested Apr 26 '22 at 16:05
  • I agree with that, `init` is not the exact `ViewDidLoad()`, but it is the best alternative. @turingtested – Saeed All Gharaee Jun 11 '22 at 09:01