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)
}
}
}
}
We can achieve this using view modifier.
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?()
}
}
}
}
Create View extension:
extension View {
func onLoad(perform action: (() -> Void)? = nil) -> some View {
modifier(ViewDidLoadModifier(perform: action))
}
}
Use like this:
struct SomeView: View {
var body: some View {
VStack {
Text("HELLO!")
}.onLoad {
print("onLoad")
}
}
}
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
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
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.
Related
I have a view that shows a sheet for filtering the items in a list. The view has this var:
struct JobsTab: View {
#State private var jobFilter: JobFilter = JobFilter()
var filter: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView($jobFilter, categoriesViewModel, jobsViewModel)
}
}
However, in the sheet, I'm trying the following and I can't make the view dismissed when clicking on the DONE button, only on the CANCEL button:
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var categoriesViewModel: CategoriesViewModel
#ObservedObject var jobsViewModel: JobsViewModel
let filterViewModel: FilterViewModel
#Binding var jobFilter: JobFilter
#State private var draft: JobFilter
#State var searchText = ""
init(_ jobFilter: Binding<JobFilter>, _ categoriesViewModel: CategoriesViewModel, _ jobsViewModel: JobsViewModel) {
_jobFilter = jobFilter
_draft = State(wrappedValue: jobFilter.wrappedValue)
self.categoriesViewModel = categoriesViewModel
self.jobsViewModel = jobsViewModel
self.filterViewModel = FilterViewModel()
}
...
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("FilterView.Button.Cancel.Text".capitalizedLocalization) {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("FilterView.Button.Done.Text".capitalizedLocalization) {
let request = Job.defaultRequest()
request.predicate = filterViewModel.buildPredicate(withJobFilterDraft: self.draft)
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(Job.publicationDate), ascending: false)]
jobsViewModel.filteredJobsFetchRequest = request
self.jobFilter = self.draft
presentationMode.wrappedValue.dismiss()
}
}
}
I have also tried with a #Binding like Paul says here but there's no luck.
Is there any workaround, or am I doing something wrong?
Thanks in advance!
EDIT: I've posted the properties of both views, because I think the problem comes from the the line in FilterView self.jobFilter = self.draft.
What I'm trying to do here is create a filter view, and the aforementioned line will be executed when the user presses the DONE button: I want to assign my binding jobFilter in the JobsTab the value of the FilterView source of truth (which is a #State) and probably, since I'm updating the binding jobFilter the FilterView is being shown again even though the $showFilter is false? I don't know to be honest.
EDIT2: I have also tried
``
if #available(iOS 15.0, *) {
let _ = Self._printChanges()
} else {
// Fallback on earlier versions
}
in both `FilterView` and its called `JobTabs` and in both, I get the same result: unchanged
According to your code, I assumed your FilterView() is not a sub view, but an independent view by its own.
Therefore, to make sure "presentationMode.wrappedValue.dismiss()" works, you don't need to create #Binding or #State variables outside the FilerView() for passing the data back and forth between different views. Just create one variable inside your FilterView() to make it works.
I don't have your full code, but I created a similar situation to your problem as below code:
import SwiftUI
struct Main: View {
#State private var showFilter = false
var body: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView()
}
}
}
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
NavigationView {
VStack {
Text("Filter View")
}.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("cancel")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("okay")
}
}
}
}
}
}
the parameter onDismiss is missing:
.sheet(isPresented: $showFilter, onDismiss: { isPresented = false }) { ... }
View model objects are usually a source of bugs because we don't use those in SwiftUI instead we use structs for speed and consistency. I recommend making an #State struct containing a bool property isSheetPresented and also the data the sheet needs. Add a mutating func and call it on the struct from the button event. Pass a binding to the struct into the sheet's view where you can call another mutating function to set the bool to false. Something like this:
struct SheetConfig {
var isPresented = false
var data: [String] = []
mutating func show(initialData: [String] ) {
isPresented = true
data = initialData
}
mutating func hide() {
isPresented = false
}
}
struct ContentView: View {
#State var config = SheetConfig()
var body: some View {
Button {
config.show(intialData: ...)
} label: {
}
.sheet(isPresented: $config.isPresented) {
FilterView($config)
}
Here is what I did to handle this issue.
Create a new protocol to avoid repeatations.
public protocol UIViewControllerHostedView where Self: View {
/// A method which should be triggered whenever dismiss is needed.
/// - Note: Since `presentationMode & isPresented` are not working for presented UIHostingControllers on lower iOS versions than 15. You must call, this method whenever you want to dismiss the presented SwiftUI.
func dismissHostedView(presentationMode: Binding<PresentationMode>)
}
public extension UIViewControllerHostedView {
func dismissHostedView(presentationMode: Binding<PresentationMode>) {
// Note: presentationMode & isPresented are not working for presented UIHostingControllers on lower iOS versions than 15.
if #available(iOS 15, *) {
presentationMode.wrappedValue.dismiss()
} else {
self.topViewController?.dismisOrPopBack()
}
}
}
Extend UIWindow and UIApplication
import UIKit
public extension UIWindow {
// Credits: - https://gist.github.com/matteodanelli/b8dcdfef39e3417ec7116a2830ff67cf
func visibleViewController() -> UIViewController? {
if let rootViewController: UIViewController = self.rootViewController {
return UIWindow.getVisibleViewControllerFrom(vc: rootViewController)
}
return nil
}
class func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
switch(vc){
case is UINavigationController:
let navigationController = vc as! UINavigationController
return UIWindow.getVisibleViewControllerFrom( vc: navigationController.visibleViewController!)
case is UITabBarController:
let tabBarController = vc as! UITabBarController
return UIWindow.getVisibleViewControllerFrom(vc: tabBarController.selectedViewController!)
default:
if let presentedViewController = vc.presentedViewController {
if let presentedViewController2 = presentedViewController.presentedViewController {
return UIWindow.getVisibleViewControllerFrom(vc: presentedViewController2)
}
else{
return vc;
}
}
else{
return vc;
}
}
}
}
#objc public extension UIApplication {
/// LCUIComponents: Returns the current visible top most window of the app.
var topWindow: UIWindow? {
return windows.first(where: { $0.isKeyWindow })
}
var topViewController: UIViewController? {
return topWindow?.visibleViewController()
}
}
Extend View to handle UIHostingController presented View`
public extension View {
weak var topViewController: UIViewController? {
UIApplication.shared.topViewController
}
}
Finally, use the helper method in your SwiftUI view as follows: -
struct YourView: View, UIViewControllerHostedView {
// MARK: - Properties
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body some View {
Button {
// Note: this part trigger a method in a created protocol above.
dismissHostedView(presentationMode: presentationMode)
} label: {
Text("Tap to dismiss")
}
}
In the sheet try adding this instead.
#Environment(\.dismiss) var dismiss // EnvironmentValue
Button("Some text") {
// Code
dismiss()
}
I have a problem with Navigation View hierarchy.
All screens in my app use the same ViewModel.
When a screen inside navigation link updates the ViewModel (here it is called DataManager), the navigation view automatically goes back to the first screen, as if the "Back" button was pressed.
Here's what it looks like
I tried to shrink my code as much as I could
struct DataModel: Identifiable, Codable {
var name: String
var isPinned: Bool = false
var id: String = UUID().uuidString
}
class DataManager: ObservableObject {
#Published private(set) var allModules: [DataModel]
var pinnedModules: [DataModel] {
allModules.filter { $0.isPinned }
}
var otherModules: [DataModel] {
allModules.filter { !$0.isPinned }
}
func pinModule(id: String) {
if let moduleIndex = allModules.firstIndex(where: { $0.id == id }) {
allModules[moduleIndex].isPinned = true
}
}
func unpinModule(id: String) {
if let moduleIndex = allModules.firstIndex(where: { $0.id == id }) {
allModules[moduleIndex].isPinned = false
}
}
static let instance = DataManager()
fileprivate init() {
allModules =
[DataModel(name: "One"),
DataModel(name: "Two"),
DataModel(name: "Three"),
DataModel(name: "Four"),
DataModel(name: "Five")]
}
}
struct ModulesList: View {
#StateObject private var dataStorage = DataManager.instance
var body: some View {
NavigationView {
List {
Section("Pinned") {
ForEach(dataStorage.pinnedModules) { module in
ModulesListCell(module: module)
}
}
Section("Other") {
ForEach(dataStorage.otherModules) { module in
ModulesListCell(module: module)
}
}
}
}
}
fileprivate struct ModulesListCell: View {
let module: DataModel
var body: some View {
NavigationLink {
SingleModuleScreen(module: module)
} label: {
Text(module.name)
}
}
}
}
struct SingleModuleScreen: View {
#State var module: DataModel
#StateObject var dataStorage = DataManager.instance
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(module.name)
.font(.title)
Button {
dataStorage.pinModule(id: module.id)
} label: {
Text("Pin")
}
}
}
}
}
I can guess because when your dataStorage changed, the ModulesList will be redrawn, that cause all current ModulesListCell removed from memory.
Your cells are NavigationLink and when it destroyed, the navigation stack doesn't keep the screen that's being linked.
I would recommend to watch this wwdc https://developer.apple.com/videos/play/wwdc2021/10022/ and you will know how to manage your view identity properly when your data's changed.
When you tap Pin, your otherModules array is recreated and you do not have the view in Navigation Stack from where you navigated. Thus you are going back automatically, which is desired behaviour. So the solution is Don't destroy your array from where your NavigationLink is created. Make a temporary published array, load Other modules from that array and change the array onAppear like below:
As a workaround which is working in my end:
Add this line in DataManger:
#Published var tempOtherModules:[DataModel] = []
Change your ModulesList like below
struct ModulesList: View {
#StateObject private var dataStorage = DataManager.instance
var body: some View {
NavigationView {
List {
Section("Pinned") {
ForEach(dataStorage.pinnedModules) { module in
ModulesListCell(module: module)
}
}
Section("Other") {
ForEach(dataStorage.tempOtherModules) { module in
ModulesListCell(module: module)
}
}
}.onAppear {
dataStorage.tempOtherModules = dataStorage.otherModules
}
}
}
}
I've got a very simple app example that has two views: a MasterView and a DetailView. The MasterView is presented inside a ContentView with a NavigationView:
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
MasterView(viewModel: MasterViewModel())
.navigationBarTitle(Text("Master"))
.navigationBarItems(
leading: EditButton()
)
}
}
}
struct MasterView: View {
#ObservedObject private var viewModel: MasterViewModel
init(viewModel: MasterViewModel) {
self.viewModel = viewModel
}
var body: some View {
print("Test")
return DataStatusView(dataSource: self.$viewModel.result) { texts -> AnyView in
print("Closure")
return AnyView(List {
ForEach(texts, id: \.self) { text in
NavigationLink(
destination: DetailView(viewModel: DetailViewModel(stringToDisplay: text))
) {
Text(text)
}
}
})
}.onAppear {
if case .waiting = self.viewModel.result {
self.viewModel.fetch()
}
}
}
}
struct DetailView: View {
#ObservedObject private var viewModel: DetailViewModel
init(viewModel: DetailViewModel) {
self.viewModel = viewModel
}
var body: some View {
self.showView().onAppear {
self.viewModel.fetch()
}
.navigationBarTitle(Text("Detail"))
}
func showView() -> some View {
switch self.viewModel.result {
case .found(let s):
return AnyView(Text(s))
default:
return AnyView(Color.red)
}
}
}
The DataStatusView is a simple view to manage some state:
public enum ResultState<T, E: Error> {
case waiting
case loading
case found(T)
case failed(E)
}
struct DataStatusView<Content, T>: View where Content: View {
#Binding private(set) var dataSource: ResultState<T, Error>
private let content: (T) -> Content
private let waitingContent: AnyView?
#inlinable init(dataSource: Binding<ResultState<T, Error>>,
waitingContent: AnyView? = nil,
#ViewBuilder content: #escaping (T) -> Content) {
self._dataSource = dataSource
self.waitingContent = waitingContent
self.content = content
}
var body: some View {
self.buildMainView()
}
private func buildMainView() -> some View {
switch self.dataSource {
case .waiting:
return AnyView(Color.red)
case .loading:
return AnyView(Color.green)
case .found(let data):
return AnyView(self.content(data))
case .failed:
return AnyView(Color.yellow)
}
}
}
and the view models are a very simple "pretend to make a network call" vm:
final class MasterViewModel: ObservableObject {
#Published var result: ResultState<[String], Error> = .waiting
init() { }
func fetch() {
self.result = .loading
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
self.result = .found(["This", "is", "a", "test"])
}
}
}
final class DetailViewModel: ObservableObject {
#Published var result: ResultState<String, Error> = .waiting
private let stringToDisplay: String
init(stringToDisplay: String) {
self.stringToDisplay = stringToDisplay
}
func fetch() {
self.result = .loading
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
self.result = .found(self.stringToDisplay)
}
}
}
Now the problem I'm having is that every time I go from Master -> Detail view the block inside the DataStatusView is called. This is a problem because the "DetailView" is constantly re-created (and therefore its vm too, which causes the loading of the detail's data to fail).
This is happening because when I go from master -> detail the buttons in the navigation bar change (or at least that's the hypothesis). When I remove the lines:
.navigationBarItems(
leading: EditButton()
)
This works as "expected".
What is the "SwiftUI" way of dealing with this? A sample project that shows this issue is here: https://github.com/kerrmarin/swiftui-mvvm-master-detail
I have already read this thread
SwiftUI - Button - How to pass a function (with parameters) request to parent from child
however after the original poster edited his own answer he proposed a way that didn't match his own question.
Unfortunately I have not yet reached enough points to post comments in this thread
This is the code example from the post above repeated to explain the problem:
struct ChildView: View {
var function: () -> Void
var body: some View {
Button(action: {
self.function()
}, label: {
Text("Button")
})
}
}
struct ContentView: View {
var body: some View {
ChildView(function: { self.setViewBackToNil() })
}
func setViewBackToNil() {
print("I am the parent")
}
}
And now I want to add a String parameter to setViewBackToNil(myStringParameter: String)
Here is possible solution. Tested with Xcode 11.4 / iOS 13.4
struct ChildView: View {
var function: (String) -> Void
#State private var value = "Child Value"
var body: some View {
Button(action: {
self.function(self.value)
}, label: {
Text("Button")
})
}
}
struct ContentView: View {
var body: some View {
ChildView { self.setViewBackToNil(myStringParameter: $0) }
}
func setViewBackToNil(myStringParameter: String) {
print("I am the parent: \(myStringParameter)")
}
}
I'm trying to setup a qr reader within a new swift ui app.
I can get load the UIKit qr reader view with this line
NavigationLink(destination: QRCodeScan()){Text("Scan QR")}
This is my ViewControllerRepresentable
struct QRCodeScan: UIViewControllerRepresentable {
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> ScannerViewController {
let vc = ScannerViewController()
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ vc: ScannerViewController, context: Context) {
}
class Coordinator: NSObject, QRCodeScannerDelegate {
func codeDidFind(_ code: String) {
print(code)
//Go back to the last page, take 'code' with you
}
var parent: QRCodeScan
init(_ parent: QRCodeScan) {
self.parent = parent
}
}
}
At the line 'Go back to the last page...' I need to programmatically return to the page which sent the user to the qr scanner. The page loads with a navigation back button, I pretty much need to replicate this buttons behaviour to call when I need
Any help/pointers appreciated
tia
struct ContentView: View {
#State var isActive = false
#State var code = ""
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: DetailView(isActive: $isActive, code: $code), isActive: $isActive, label: { EmptyView() })
Button(action: {
self.isActive.toggle()
}, label: {
Text("navigate")
})
}
}
}
}
struct DetailView: View {
#Binding var isActive: Bool
#Binding var code: String
var body: some View {
Button(action: {
self.code = "new code"
self.isActive.toggle()
}) {
Text("Back")
}
}
}
This might help you, use isActive parameter of NavigationLink to navigate back and forth
The short answer is you can't do that right now. There is neither a binding nor an environment value to set that can trigger this. My guess is there will be some kind of environment value akin to presentationMode that you can tap into but it isn't currently advertised.
You could try the current presentationMode but my real suggestion is to present your QR scanner as a sheet rather than a push. This may actually make more sense from a navigational standpoint anyway. To do it this way, in your presenter set up a #State var to handle when it's presented.
#State var presentQRScanner = false
var body: some View {
Button("Scan") {
self.presentQRScanner = true
}
.sheet(isPresented: $presentQRScanner) { QRCodeScan() }
}
Then, when you want to programmatically dismiss, your UIViewControllerRepresentable:
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
func scannedCode() {
presentationMode.wrappedValue.dismiss()
}
Alternatively, you can drive this from the presenter too by creating a closure on the QRCodeScan that gets invoked with the code and you have your presenter dismiss.
var onCodeScanned: (Code) -> Void = { _ in }
func scannedCode() {
onCodeScanned(code)
}
and in the presenter:
var body: some View {
Button("Scan") {
self.presentQRScanner = true
}
.sheet(isPresented: $presentQRScanner) {
QRCodeScan(onCodeScanned: {
self.process($0)
self.presentQRScanner = false
})
}
}
EDIT: was not aware of the isActive binding, that should actually work for you if you still want to push your view on the nav stack instead of present it.
You can do it with the following overload of the NavigationLink. It's available since iOS 13 and later.
Here's the code.
Pay attention to passing $isShowingView binding to the both NavigationLink object and to the ChildView that you want to go out on button tap.
struct ContentView: View {
#State var isShowingChildView = false
var body: some View {
NavigationView {
NavigationLink(isActive: $isShowingChildView) {
ChildView(isShowingView: $isShowingChildView)
} label: {
Text("Open new view")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ChildView: View {
#Binding
var isShowingView: Bool
var body: some View {
VStack {
Button("Back to parent view") {
isShowingView = false
}
}
}
}