How to provide a ‘System’ appearance choice in SwiftUI? - ios

Main Question
Sorry for the long title. I’m trying to figure out how to provide a choice to user’s for setting the ColorScheme of the app to ‘System’.
As in, have the app default to the system’s dark mode / light mode. I know UIKit has a way to set the style to .unspecified but I’m not sure how to access that in SwiftUI.
I already tried setting .preferredColor() to nil and it kind of sort of works but sometimes it doesn’t. I’m new to managing state in Swift so I’m definitely doing something wrong.
Side Question:
When I set .preferredColor() to say .dark - the modal settings sheet I have doesn’t update? It always stays the system colour. I have .preferredColor() triggering on the ContentView() in the ...App.swift file.
Update
I solved my Side Question by just adding .preferredColor() to the modal sheet as well. It's not ideal but it does work. However I'm still unable to figure out how to set a .system preference in SwiftUI

I ended up finding two solutions to this problem. The first was found by jinjie and the other by me. Both solutions can be viewed here - repo. created by rizwankce.
Solution
What I ended up doing was using the package Introspect to grab the UIViewController off the main view I have using a ViewModifier.
Within the ViewModifier I just used .introspectViewController and that gave me the UIViewController so that I could then set UIViewController.overrideUserInterfaceStyle = .unspecified like this. Then I just did this conditionally based on a Picker not seen below. ⤵
/// All themes
enum Themes: String {
case Dark
case Light
case System
}
// MARK: - Theme Switch
struct ThemeSwitch: ViewModifier {
let appStorage: String
func body(content: Content) -> some View {
content
.introspectViewController { UIViewController in
switch appStorage {
case Themes.System.rawValue: UIViewController.overrideUserInterfaceStyle = .unspecified
case Themes.Dark.rawValue: UIViewController.overrideUserInterfaceStyle = .dark
case Themes.Light.rawValue: UIViewController.overrideUserInterfaceStyle = .light
default: UIViewController.overrideUserInterfaceStyle = .unspecified
}
}
}
}
// MARK: - Extensions
extension View {
func themeSwitch(appStorage: String) -> some View {
modifier(ThemeSwitch(appStorage: appStorage))
}
}
I just stored the desired theme as a String into a #State object which also updated an #AppStorage object so the app always had the selection no matter what.
You don't have to use Introspect and could instead create your own wrapper but I already had this package installed so I figured why not.
Alternative Solutions
The other solution is to make a view modifier and display the modified view conditionally. The condition is based on an optional array, dictionary, etc. of type ColorScheme. It either unwraps the optional if it's not nil or it just shows the unmodified view. You can find it at the repo. mentioned above under closed issues or pull requests.
In the future hopefully Apple will provide a way to do this natively but for now this will do! I'll try to update this answer in the future if something better comes along.

Related

UIView overrideUserInterfaceStyle is not working

I want the app to adhere to the system light/dark modes, but one child view to be stuck to light mode.
So technically, this should work:
myChildView.overrideUserInterfaceStyle = .light
This does not work!
So there are a few methods to override the interface style:
UIApplication.current.windows.first.overrideUserInterfaceStyle = .light
This works where it locks the whole app to light mode.
myViewController.overrideUserInterfaceStyle = .light
This does not work in any of my view controllers.
myViewControlller.view.overrideUserInterfaceStyle = .light
This also doesn't work.
myChildView.overrideUserInterfaceStyle = .light
This is what I need to work, but doesn't.
I also do not have it specified in the info.plist to lock the whole app into any mode.
Am I missing something?
UPDATE
The issue is still present on iOS 15 or iOS 16 betas. Above methods don't seem to work:
https://developer.apple.com/documentation/uikit/appearance_customization/supporting_dark_mode_in_your_interface/choosing_a_specific_interface_style_for_your_ios_app
I use this with UITraitCollection(userInterfaceLevel: .elevated) but should work just as well with userInterfaceStyle. (You will get some logs saying you shouldn't override traitCollection, just ignore them)
override var traitCollection: UITraitCollection {
UITraitCollection(traitsFrom: [super.traitCollection, UITraitCollection(userInterfaceStyle: .light)])
}
The documentation for what you are trying to use mentions some exceptions, I don't know if that is what makes it not work for you: https://developer.apple.com/documentation/uikit/uiview/3238086-overrideuserinterfacestyle
If you assign a different value, the new style applies to the view and all of the subviews owned by the same view controller. (If the view hierarchy contains the root view of an embedded child view controller, the child view controller and its views do not inherit the interface style.)

What causes an 'AttributeGraph precondition failure' when using a TabView?

I had a single view application wrapped in a NavigationView where I populate data fetched from an API. However, I needed to add a profile section with a TabView and sometimes when I run the application, right when the launch screen is displayed, I get the following error (about once every three times I run/ debug the app) and the app crashes.
AttributeGraph precondition failure: invalid value type for attribute: 56028 (expected PlatformItemList, saw BridgedTableViewState)
The view that causes the error:
TabView {
NavigationView {
MainView()
}
.tabItem { VStack {
Image(systemName: "list.dash")
Text("main")
}
}
NavigationView {
ProfileView()
}
.tabItem { VStack {
Image(systemName: "person.crop.circle")
Text("profile")
}
}
}
I removed the TabView and the profile view and the app runs without any exceptions consistently, which is why I thought it was an issue with the TabView. I searched for documentation on AttributeGraph, PlatformItemList and BridgedTableViewState but found none.
I had a related error: AttributeGraph precondition failure: invalid value type for attribute: 431640 (saw PreferenceKeys, expected ViewList).
This error did not arise in an app with almost identical functional and database code but using UIKit and manually-constructed tab view, list view etc. Only in the SwiftUI version with SwiftUI structs for these views.
Removing the Persistence class and persistence management seemed to resolve the issue (I use a direct SQLite coding for persistence instead of CoreData).
The Xcode error messages could be more helpful.
I had experienced same problem. It was caused by combination of AnyView and NSViewRepresentable wrapper for WKWebView. Getting rid of AnyView by rewriting helper function with #ViewBuilder fixed the problem.
Problem:
func viewFunc() -> AnyView {
switch self {
case .case1: return AnyView(ListOfWebViewWrappers())
default: return AnyView(WaitView())
}
}
Solution:
#ViewBuilder
func viewFunc() -> some View {
switch self {
case .case1: ListOfWebViewWrappers()
default: WaitView()
}
}
the #ViewBuilder allows SwiftUI to properly track each view's identity and properly manage resources. More information about view's identity: https://developer.apple.com/videos/play/wwdc2021/10022/
I spent a lot of time dealing with precondition failure crashes in an app with multiple tab views. Is there any chance that one or more of your specific views (ProfileView or MainView) is also using a NavigationView? If so, that's probably the problem. Xcode will happily let you add NavigationViews in each new view, but it's not actually the way SwiftUI is meant to work. Since each TabView is wrapped in a NavView, then you shouldn't need/use a NavView inside of those views.
(Also, probably just cosmetic, but in the code snippet above there seems to be an extra close curly brace before your second Nav view.)

SwiftUI: Change only one instance of UITableViewAppearance

I know that the component List has an UITableView behind. I also know I can change the background by changing the UITableView.appearance().backgroundColor.
What I want is to change this for a single View that has a List component, without affecting the rest of the application. Is this possible? If so, how?
Thanks!
This is what I know needs to be done to achieve this:
var body: some View {
List {
//Your content
}
.onAppear {
//The specific table view settings.
//UITableView.appearance().separatorColor = .black
}
.onDisappear {
//The general table view settings
//UITableView.appearance().separatorColor = nil
}
}
But it is problematic for several reasons.
The biggest reason is the fact you might have 2 table views in the same View and you want them to have different appearances.
The second issue is that not all cases will call onAppear (I don't remember specific cases but I remember having issues with TabViews)
I hope there's already a better solution waiting in the next version of SwiftUI.

How to update a SwiftUI view state from outside (UIViewController for example)

I have a SwiftUI view:
struct CatView : View {
#State var eyesOpened: Bool = false
var body: some View {
Image(uiImage: eyesOpened ? #imageLiteral(resourceName: "OpenedEyesCat") : #imageLiteral(resourceName: "ClosedEyesCat"))
}
}
I'm trying to integrate it with in a regular UIViewController.
let hostingVC = UIHostingController<CatView>(rootView: cat)
addChild(hostingVC)
view.addSubview(hostingVC.view)
hostingVC.view.pinToBounds(of: view)
Now in the UIViewController if I try to set the eyesOpened property I get a
Thread 1: Fatal error: Accessing State<Bool> outside View.body
How are we supposed to make this work? Are SwiftUI views not supposed to work in this scenario?
#State is the wrong thing to use here. You'll need to use #ObservedObject.
#State: Used for when changes occur locally to your SwiftUI view - ie you change eyesOpened from a toggle or a button etc from within the SwiftUI view it self.
#ObservedObject: Binds your SwiftUI view to an external data source - ie an incoming notification or a change in your database, something external to your SwiftUI view.
I would highly recommend you watch the following WWDC video - Data Flow Through SwiftUI
There is something called the notification center that you could use. In short, it is a way that views can communicate between each other without actually modifying anything in each other.
How it works is view A sends a notification to a central hub from which view B hears said notification. When view B hears the notification it activates, and calls a function defined by the user.
For a more detailed explanation, you can refer to:
https://learnappmaking.com/notification-center-how-to-swift/

How update a SwiftUI List without animation

I want to update a SwiftUI List without any insert animation.
My List is getting its data from an #EnvironmentObject
I already tried to wrap the List itself and the PassthroughSubject.send() in a withAnimation(.empty) block but this does not help.
A very very dirty workaround is to call UIView.setAnimationsEnabled(false) (yes, UIKit has impact on SwiftUI), but there must be a SwiftUI-like way to set custom insert animations.
While the answer provided by DogCoffee works, it does so in an inefficient manner. Sometimes we do have to force the system to do what we want by being inefficient. In the case of implicit animations in SwiftUI, there is a better way to disable them.
Using the Transaction mechanism in SwiftUI, we can define an extension that can be applied to any view. This will disable animations for the view and any children.
For the list view example, this avoids replacing all the data in the list with a new, but identical copies.
extension View {
func animationsDisabled() -> some View {
return self.transaction { (tx: inout Transaction) in
tx.disablesAnimations = true
tx.animation = nil
}.animation(nil)
}
}
Try applying this extension to your list, or the parent containing view. You may have to experiment to find which view is ideal.
List {
// for each etc
}.animationsDisabled()
This works, just place .id(UUID()) at the end of your list
List {
// for each etc
}.id(UUID())
Sort of like reloadData for UIKit
on tvOS this works for me:
List {
...
}
.animation(.none)
.animate(nil)
you can find more info on https://developer.apple.com/tutorials/swiftui/animating-views-and-transitions

Resources