SwiftUI - Using #main to set the rootViewController - swiftui

When using the SceneDelegate in SwiftUI, it was possible to create a function like the one below that could be used to set the view as shown here. However, in the latest version we now use a WindowsGroup. Is it possible to write a function that changes the view in the WindowsGroup?
func toContentView() {
let contentView = ContentView()
window?.rootViewController = UIHostingController(rootView: contentView)
}

Here is possible alternate approach that do actually the same as your old toContentView
helper class
class Resetter: ObservableObject {
static let shared = Resetter()
#Published private(set) var contentID = UUID()
func toContentView() {
contentID = UUID()
}
}
content of #main
#StateObject var resetter = Resetter.shared
var body: some Scene {
WindowGroup {
ContentView()
.id(resetter.contentID)
}
}
now from anywhere in code to reset to ContentView you can just call
Resetter.shared.toContentView()

Related

How to implement singleton #ObservedObject in SwiftUI [duplicate]

I want to create a global variable for showing a loadingView, I tried lots of different ways but could not figure out how to. I need to be able to access this variable across the entire application and update the MotherView file when I change the boolean for the singleton.
struct MotherView: View {
#StateObject var viewRouter = ViewRouter()
var body: some View {
if isLoading { //isLoading needs to be on a singleton instance
Loading()
}
switch viewRouter.currentPage {
case .page1:
ContentView()
case .page2:
PostList()
}
}
}
struct MotherView_Previews: PreviewProvider {
static var previews: some View {
MotherView(viewRouter: ViewRouter())
}
}
I have tried the below singleton but it does not let me update the shared instance? How do I update a singleton instance?
struct LoadingSingleton {
static let shared = LoadingSingleton()
var isLoading = false
private init() { }
}
Make your singleton a ObservableObject with #Published properties:
struct ContentView: View {
#StateObject var loading = LoadingSingleton.shared
var body: some View {
if loading.isLoading {
Text("Loading...")
}
ChildView()
Button(action: { loading.isLoading.toggle() }) {
Text("Toggle loading")
}
}
}
struct ChildView : View {
#StateObject var loading = LoadingSingleton.shared
var body: some View {
if loading.isLoading {
Text("Child is loading")
}
}
}
class LoadingSingleton : ObservableObject {
static let shared = LoadingSingleton()
#Published var isLoading = false
private init() { }
}
I should mention that in SwiftUI, it's common to use .environmentObject to pass a dependency through the view hierarchy rather than using a singleton -- it might be worth looking into.
First, make LoadingSingleton a class that adheres to the ObservableObject protocol. Use the #Published property wrapper on isLoading so that your SwiftUI views update when it's changed.
class LoadingSingleton: ObservableObject {
#Published var isLoading = false
}
Then, put LoadingSingleton in your SceneDelegate and hook it into your SwiftUI views via environmentObject():
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
static let singleton = LoadingSingleton()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(SceneDelegate.singleton))
self.window = window
window.makeKeyAndVisible()
}
}
}
To enable your SwiftUI views to update when changing isLoading, declare a variable in the view's struct, like this:
struct MyView: View {
#EnvironmentObject var singleton: LoadingSingleton
var body: some View {
//Do something with singleton.isLoading
}
}
When you want to change the value of isLoading, just access it via SceneDelegate.singleton.isLoading, or, inside a SwiftUI view, via singleton.isLoading.

EnvironmentObject causes unrelated ObservedObject to reset

I am not quite sure I understand what is going on here as I am experimenting with an EnvironmentObject in SwiftUI.
I recreated my problem with a small example below, but to summarize: I have a ContentView, ContentViewModel, and a StateController. The ContentView holds a TextField that binds with the ContentViewModel. This works as expected. However, if I update a value in the StateController (which to me should be completely unrelated to the ContentViewModel) the text in the TextField is rest.
Can someone explain to me why this is happening, and how you could update a state on an EnvironmentObject without having SwiftUI redraw unrelated parts?
App.swift
#main
struct EnvironmentTestApp: App {
#ObservedObject var stateController = StateController()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(stateController)
}
}
}
ContentView.swift
struct ContentView: View {
#ObservedObject private var viewModel = ContentViewModel()
#EnvironmentObject private var stateController: StateController
var body: some View {
HStack {
TextField("Username", text: $viewModel.username)
Button("Update state") {
stateController.validated = true
}
}
}
}
ContentViewModel.swift
class ContentViewModel: ObservableObject {
#Published var username = ""
}
StateController.swift
class StateController: ObservableObject {
#Published var validated = false
}
Like lorem-ipsum pointed out, you should use #StateObject.
A good rule of thumb is to use #StateObject every time you init a viewModel, but use #ObservedObject when you are passing in a viewModel that has already been init.

Why Doesn't Instantiation In SwiftUI #main App Not Create An EnvironmentObject?

I am working with SwiftUI and #EnvironmentObjects. I am using the SwiftUI App Lifecycle. In this file I create a #StateObjects for ListingRepository and attach it to ContentView() with .envrionmentObjects().
struct MyApp: App {
// #EnvironmentObjects
#StateObject private var listingRepository = ListingRepository()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(listingRepository)
}
}
}
My assumption was that I could now access listingRepository by using an #EnvironmentObject wrapper. However, it seems I must instantiate this again. Below is my ListingViewModel. My first attempt looked like the following.
class MarketplaceViewModel: ObservableObject {
#EnvironmentObject var listingRepository: ListingRepository
#Published var listingRowViewModels = [ListingRowViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
listingRepository
.$listings
.receive(on: RunLoop.main)
.map { listings in
listings.map { listing in
ListingRowViewModel(listing: listing)
}
}
.assign(to: \.listingRowViewModels, on: self)
.store(in: &cancellables)
}
}
This threw the following error.
Fatal error: No ObservableObject of type ListingRepository found. A
View.environmentObject(_:) for ListingRepository may be missing as an
ancestor of this view.
The second option uses #Published and fixes the error.
class MarketplaceViewModel: ObservableObject {
#Published var listingRepository = ListingRepository()
#Published var listingRowViewModels = [ListingRowViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
listingRepository
.$listings
.receive(on: RunLoop.main)
.map { listings in
listings.map { listing in
ListingRowViewModel(listing: listing)
}
}
.assign(to: \.listingRowViewModels, on: self)
.store(in: &cancellables)
}
}
My questions are the following.
Is it necessary to instantiate twice like I am?
Why doesn't the instantiation in #main App work?
in your #main App you should define your environment object like this:
struct MyApp: App {
// #EnvironmentObjects
var listingRepository = ListingRepository()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(listingRepository)
}
}
}
Then it suffices to have the ListingRepository define as:
class ListingRepository: ObservableObject {
#Published var ...
#Published var ...
// Your code here
}
Don't redeclare the ListingRepository as an EnvironmentObject in your MarketPlaceViewModel.
One note, however, it is not wise to make your ListingRepository an observable object and access this object from your views through the MarketPlaceViewModel. If the MarketPlaceViewModel is used to fill the views in your app and the MarketPlaceViewModel gets the data from the ListingRepository, you should make your MarketPlaceViewModel the EnvironmentObject and not the ListingRepository.
The thing in SwiftUI is the you want the EnvironmentObject to publish changes to several views in your app, so that these views can reconstruct themselves. If you use a view model between your model and your view, the view model should be the EnvironmentObject.

Bizarre SwiftUI behavior: ViewModel class + #Binding is breaking when using #Environment(\.presentationMode)

I keep finding very strange SwiftUI bugs that only pop up under very specific circumstances 😅. For example, I have a form that is shown as a model sheet. This form has a ViewModel, and shows a UITextView (via UIViewRepresentable and a #Binding - it's all in the code below).
Everything works absolutely fine, you can run the code below and you'll see all the two-way bindings working as expected: type in one field and it changes in the other, and vice-versa. However, as soon as you un-comment the line #Environment(\.presentationMode) private var presentationMode, then the two-way binding in the TextView breaks. You will also notice that the ViewModel prints "HERE" twice.
What the hell is going on? My guess is that as soon as ContentView shows the modal, the value of presentationMode changes, which then re-renders the sheet (so, FormView). That would explain the duplicate "HERE" getting logged. But, why does that break the two-way text binding?
One workaround is to not use a ViewModel, and simply have an #State property directly in the FormView. But that is not a great solution as I have a bunch of logic in my real-world form, which I don't want to move to the form view. So, does anyone have a better solution?
import SwiftUI
import UIKit
struct TextView: UIViewRepresentable {
#Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let uiTextView = UITextView()
uiTextView.delegate = context.coordinator
return uiTextView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = self.text
}
class Coordinator : NSObject, UITextViewDelegate {
var parent: TextView
init(_ view: TextView) {
self.parent = view
}
func textViewDidChange(_ textView: UITextView) {
self.parent.text = textView.text
}
func textViewDidEndEditing(_ textView: UITextView) {
self.parent.text = textView.text
}
}
}
struct ContentView: View {
#State private var showForm = false
//#Environment(\.presentationMode) private var presentationMode
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView()
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject private var viewModel = ViewModel()
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
I finally found a workaround: store the ViewModel on the ContentView, not on the FormView, and pass it in to the FormView.
struct ContentView: View {
#State private var showForm = false
#Environment(\.presentationMode) private var presentationMode
private let viewModel = ViewModel()
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView(viewModel: self.viewModel)
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
The only thing is that the ViewModel is now instantiated right when the ContentView is opened, even if you never open the FormView. Feels a bit wasteful. Especially when you have a big List, with NavigationLinks to a bunch of detail pages, which now all create their presented-as-a-sheet FormView's ViewModel up front, even if you never leave the List page.
Sadly I can't turn the ViewModel into a struct, as I actually need to (asynchronously) mutate state and then eventually I run into the Escaping closure captures mutating 'self' parameter compiler error. Sigh. So yeah, I am stuck with using a class.
The root of the issue is still that FormView is instantiated twice (because of #Environment(\.presentationMode)), which causes two ViewModels to be created as well (which my workaround solves by passing in one copy to both FormViews basically). But it's still weird that this broke #Binding, since the standard TextFields did work as expected.
There are still a lot of weird gotcha's like this with SwiftUI, I really hope this becomes simpler to manage soon. If anyone can explain the behavior of sheets, ObservableObject classes (viewmodels), #Environment(\.presentationMode) and #Binding put together, I'm all ears.

SwiftUI: How to properly present AVPlayerViewController modally?

Proper UIKit Approach:
According to Apple's WWDC 2019 talk on the subject, AVPlayerViewController should be presented modally to take advantage of all the latest full-screen features of the API. This is the recommended sample code to be called from your presenting UIKit view controller:
// Create the player
let player = AVPlayer(url: videoURL)
// Create the player view controller and associate the player
let playerViewController = AVPlayerViewController()
playerViewController.player = player
// Present the player view controller modally
present(playerViewController, animated: true)
This works as expected and launches the video in beautiful full-screen.
Achieve the Same Effect with SwiftUI?:
In order to use the AVPlayerViewController from SwiftUI, I created the UIViewControllerRepresentable implementation:
struct AVPlayerView: UIViewControllerRepresentable {
#Binding var videoURL: URL
private var player: AVPlayer {
return AVPlayer(url: videoURL)
}
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.player = player
playerController.player?.play()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
return AVPlayerViewController()
}
}
I cannot seem to figure out how to present this directly from SwiftUI
in the same way as the AVPlayerViewController is presented directly
from UIKit. My goal is simply to get all of the default, full-screen benefits.
So far, the following has not worked:
If I use a .sheet modifier and present it from within the sheet, then the player is embedded in a sheet and not presented full-screen.
I have also tried to create a custom, empty view controller in UIKit that simply presents my AVPlayerViewController modally from the viewDidAppear method. This gets the player to take on the full screen, but it also shows an empty view controller prior to display the video, which I do not want the user to see.
Any thoughts would be much appreciated!
Just a thought if you like to fullscreen similar like UIKit, did you try the following code from ContentView.
import SwiftUI
import UIKit
import AVKit
struct ContentView: View {
let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
#State private var vURL = URL(string: "https://www.radiantmediaplayer.com/media/bbb-360p.mp4")
var body: some View {
AVPlayerView(videoURL: self.$vURL).transition(.move(edge: .bottom)).edgesIgnoringSafeArea(.all)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct AVPlayerView: UIViewControllerRepresentable {
#Binding var videoURL: URL?
private var player: AVPlayer {
return AVPlayer(url: videoURL!)
}
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
playerController.modalPresentationStyle = .fullScreen
playerController.player = player
playerController.player?.play()
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
return AVPlayerViewController()
}
}
The solution explained by Razib-Mollick was a good start for me, but it was missing the use of the SwiftUI .sheet() method. I have added this by adding the following to ContentView:
#State private var showVideoPlayer = false
var body: some View {
Button(action: { self.showVideoPlayer = true }) {
Text("Start video")
}
.sheet(isPresented: $showVideoPlayer) {
AVPlayerView(videoURL: self.$vURL)
.edgesIgnoringSafeArea(.all)
}
}
But the problem is then, that the AVPlayer is instantiated again and again when SwiftUI re-renders the UI.
Therefore the state of the AVPlayer has to move to a class object stored in the environment, so we can get hold of it from the View struct. So my latest solution looks now as follows. I hope it helps somebody else.
class PlayerState: ObservableObject {
public var currentPlayer: AVPlayer?
private var videoUrl : URL?
public func player(for url: URL) -> AVPlayer {
if let player = currentPlayer, url == videoUrl {
return player
}
currentPlayer = AVPlayer(url: url)
videoUrl = url
return currentPlayer!
}
}
struct ContentView: View {
#EnvironmentObject var playerState : PlayerState
#State private var vURL = URL(string: "https://www.radiantmediaplayer.com/media/bbb-360p.mp4")
#State private var showVideoPlayer = false
var body: some View {
Button(action: { self.showVideoPlayer = true }) {
Text("Start video")
}
.sheet(isPresented: $showVideoPlayer, onDismiss: { self.playerState.currentPlayer?.pause() }) {
AVPlayerView(videoURL: self.$vURL)
.edgesIgnoringSafeArea(.all)
.environmentObject(self.playerState)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(PlayerState())
}
}
struct AVPlayerView: UIViewControllerRepresentable {
#EnvironmentObject var playerState : PlayerState
#Binding var videoURL: URL?
func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
let playerController = AVPlayerViewController()
playerController.modalPresentationStyle = .fullScreen
playerController.player = playerState.player(for: videoURL!)
playerController.player?.play()
return playerController
}
}
Something to be aware of (a bug?): whenever a modal sheet is shown using .sheet() the environment objects are not automatically passed to the subviews. They have to be added using environmentObject().
Here is a link to read more about this problem: https://oleb.net/2020/sheet-environment/
Xcode 12 · iOS 14
→ Use .fullScreenCover instead of .sheet and you’re good to go.
See also: How to present a full screen modal view using fullScreenCover