Combining environment variables into one in SwiftUI - swiftui

My apps adapt layout based on horizontal size class and ContentSizeCategory. So, I typically have code like this:
#Environment(\.horizontalSizeClass) var horizontalSizeClass
#Environment(\.sizeCategory) var sizeCategory: ContentSizeCategory
private var isHorCompactLayout: Bool {
horizontalSizeClass == .compact || sizeCategory.isAccessibilityCategory
}
which I use like this:
var body: some Scene {
if isHorCompactLayout {
Text("CompactLayout()")
} else {
Text("NormalLayout()")
}
I'd like to refactor the first chunk of code to avoid repeating it in all views where I adapt the layout. How can this be done?
I suppose I could create a new view, pass it the two views and render the correct one based on the result of isHorCompactLayout. But it would still be good to get the value of isHorCompactLayout when needed; for example, to adjust padding.

A possible approach is to have own environment value and set at the very root level of your content view, so all other subviews will have it update on changes at root level, like
struct ContentView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
#Environment(\.sizeCategory) var sizeCategory
var body: some View {
VStack { // some top-most container or root view
// ... all app hierarchy
}
.environment(\.isHorCompactLayout, horizontalSizeClass == .compact || sizeCategory.isAccessibilityCategory)
}
}
struct HorCompactLayoutKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var isHorCompactLayout: Bool {
get { self[HorCompactLayoutKey.self] }
set { self[HorCompactLayoutKey.self] = newValue }
}
}
Tested with Xcode 13.4

Related

Is it possible to extract logic that depends on the Environment outside of the struct that uses it?

Is it possible to extract logic that depends on the SwiftUI environment outside of the views?
For example, consider the scenario where you have a Theme struct that computes a color depending on a property in the environment, something akin to the sample code below.
What I'd like to do is extract out the logic that computes a color so that it can be used in multiple places. Ideally I'd like to use #Environment in the Theme struct so that I only have to retrieve the value it in once place - the alternative is that I retrieve from the environment at the call site of my Theme computation and inject the value in. That alternative works fine, but I'd like to avoid the need to retrieve the environment value all over the place.
/// A structure to encapsulate common logic, but the logic depends on values in the environment.
struct Theme {
#Environment(\.isEnabled) var isEnabled: Bool
var color: Color {
isEnabled ? .blue : .gray
}
}
/// One of many views that require the logic above
struct MyView: View {
let theme: Theme
var body: some View {
theme.color
}
}
/// A little app to simulate the environment values changing
struct MyApp: App {
#State var disabled: Bool
var body: some Scene {
WindowGroup {
VStack {
Toggle("Disabled", isOn: $disabled)
MyView(theme: Theme())
.disabled(disabled)
}
}
}
}
The Sample code above doesn't work, ie if you toggle the switch in the app the View's color does not change. This sample code only serves to show how I'd ideally like it to work, particularly because it doesn't require me to litter #Environment throughout MyView and similar views just to retrieve the value and pass it into a shared function.
One thing that I thought could be causing the problem is that the Theme is created outside of the scope where the Environment is changing, but if I construct a Theme inside MyView the behaviour doesn't change.
My confusion here indicates that I'm missing something fundamental in my understanding of the SwiftUI Environment. I'd love to understand why that sample code doesn't work. If Theme were a View with the color logic in its body, it would be updating, so why doesn't it cause an update in it's current setup?
The approach should be different, view parts in views, model parts in models, "separate and rule"
struct Theme { // << view layer independent !!
func color(for enabled: Bool) -> Color { // << dependency injection !!
enabled ? .blue : .gray
}
}
struct MyView: View {
#Environment(\.isEnabled) var isEnabled: Bool
let theme: Theme
var body: some View {
theme.color(for: isEnabled) // << here !!
}
}
I feel like you're mixing a few things, so let me tell you how I'd structure this.
First of all, I don't think a theme should have state, it should be a repository of colors.
struct Theme {
var enabledColor: Color = .blue
var disabledColor: Color = .gray
}
Your View is where you should have your state
struct MyView: View {
#Environment(\.isEnabled) var isEnabled: Bool
var body: some View {
// ??
}
}
What I would suggest is that you create your own EnvironmentKey and inject your theme into the environment:
struct ThemeKey: EnvironmentKey {
static let defaultValue: Theme = .init()
}
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
Now your View can use the environment to read the theme. I like to keep my views light of logic, but presentation logic in them makes sense to me.
struct MyView: View {
#Environment(\.theme) var theme
#Environment(\.isEnabled) var isEnabled: Bool
var body: some View {
isEnabled ? theme.enabledColor : theme.disabledColor
}
}
You'll need to inject your theme at some point in your app, you should add it near the top of the view hierarchy to make sure all views get access to it.
struct MyApp: App {
#State var disabled: Bool
#State var theme: Theme = .init()
var body: some Scene {
WindowGroup {
VStack {
Toggle("Disabled", isOn: $disabled)
MyView()
.disabled(disabled)
}
.environment(\.theme, theme)
}
}
}
I wrote this article about using the environment to provide values to the view hierarchy, you may find it useful.

Pass in default text in TextView while keeping state changes with SwiftUI

I am trying to set a default text on a TextView when the view appears, while being able to still keep track of changes to the TextView that I can then pass on to my ViewModel.
Here is a little example that looks like what I am trying to do. This does however not work, it does not update the state as I would have expected. Am I doing something wrong?
struct NoteView: View {
#State var note = ""
var noteFromOutside: String?
var body: some View {
VStack {
TextField("Write a note...", text: $note)
.onSubmit {
//Do something with the note.
}
}
.onAppear {
if let newNote = noteFromOutside {
note = newNote
}
}
}
}
struct ParentView: View {
var note = "Note"
var body: some View {
VStack {
NoteView(noteFromOutside: note)
}
}
}
Found this answer to another post which solved my problem. The key was in the #Binding and init().
https://stackoverflow.com/a/64526620/12764203

SwiftUI clean up ContentView

I'm trying to simplify the ContentView within a project and I'm struggling to understand how to move #State based logic into its own file and have ContentView adapt to any changes. Currently I have dynamic views that display themselves based on #Binding actions which I'm passing the $binding down the view hierarchy to have buttons toggle the bool values.
Here's my current attempt. I'm not sure how in SwiftUI to change the view state of SheetPresenter from a nested view without passing the $binding all the way down the view stack. Ideally I'd like it to look like ContentView.overlay(sheetPresenter($isOpen, $present).
Also, I'm learning SwiftUI so if this isn't the best approach please provide guidance.
class SheetPresenter: ObservableObject {
#Published var present: Present = .none
#State var isOpen: Bool = false
enum Present {
case none, login, register
}
#ViewBuilder
func makeView(with presenter: Present) -> some View {
switch presenter {
case .none:
EmptyView()
case .login:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
LoginScreen()
}
case .register:
BottomSheetView(isOpen: $isOpen, maxHeight: UIConfig.Utils.screenHeight * 0.75) {
RegisterScreen()
}
}
}
}
if you don't want to pass $binding all the way down the view you can create a StateObject variable in the top view and pass it with .environmentObject(). and access it from any view with EnvironmentObject
struct testApp: App {
#StateObject var s1: sViewModel = sViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(s1)
}
}
}
You are correct this is not the best approach, however it is a common mistake. In SwiftUI we actually use #State for transient data owned by the view. This means using a value type like a struct, not classes. This is explained at 4:18 in Data Essentials in SwiftUI from WWDC 2020.
EditorConfig can maintain invariants on its properties and be tested
independently. And because EditorConfig is a value type, any change to
a property of EditorConfig, like its progress, is visible as a change
to EditorConfig itself.
struct EditorConfig {
var isEditorPresented = false
var note = ""
var progress: Double = 0
mutating func present(initialProgress: Double) {
progress = initialProgress
note = ""
isEditorPresented = true
}
}
struct BookView: View {
#State private var editorConfig = EditorConfig()
func presentEditor() { editorConfig.present(…) }
var body: some View {
…
Button(action: presentEditor) { … }
…
}
}
Then you just use $editorConfig.isEditorPresented as the boolean binding in .sheet or .overlay.
Worth also taking a look at sheet(item:onDismiss:content:) which makes it much simpler to show an item because no boolean is required it uses an optional #State which you can set to nil to dismiss.

Apply a navigationStyle based on horizontalSizeClass for the root NavigationView and preserve the navigation stack

On app launch, I want to get the horizontalSizeClass and based on if it's compact or regular, apply a navigation style to my root navigation view like so:
import SwiftUI
#main
struct MyApp: App {
#Environment(\.horizontalSizeClass) var sizeClass
var body: some Scene {
WindowGroup {
if sizeClass == .compact {
NavigationView {
Text("Compact size class inside stack navigation style")
}
.navigationViewStyle(StackNavigationViewStyle())
} else {
NavigationView {
Text("Regular size class inside default navigation style")
}
}
}
}
}
However, sizeClass always returns nil in this case.
How do I
determine if the horizontal size class is compact or regular on the root view, and
make the navigation style adapt to the size class any time it changes
My app is targeting iOS 14 for both iPhone and iPad.
Any help or a different approach to adapt for size class changes for the whole app is much appreciated.
Update 1
I tried the suggestions to use a ViewModifier or creating a custom view and adding the navigation in it's body like so:
import SwiftUI
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
MyRootView()
}
}
}
struct MyRootView: View {
#Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
if sizeClass == .compact {
NavigationView {
Text("Compact size class inside stack navigation style")
}
.navigationViewStyle(StackNavigationViewStyle())
} else {
NavigationView {
Text("Regular size class inside default navigation style")
}
}
}
}
However, the navigation stack pops to the root view every time the sizeClass changes. Is there a way to preserve the stack? For example: If the user is 5 levels deep in navigation, and sizeClass changes, change the navigation style while keeping the visible screen?
Thank you!
Update 2
I was able to find a WWDC session explaining exactly what I want, but it's in UIKit.
See 18:35 here: https://developer.apple.com/wwdc20/10105
I'm trying to achieve the same goal in SwiftUI (keep the screen the user selected while changing the size class to compact).
According to the session, UISplitViewController supports this because there's the concept of Restorable and Restore in the detail view. I can't find a way to do this in SwiftUI.
this setup works for me. I read somewhere in the docs that Environment are updated before a view is rendered.
I guess App is not a view.
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .compact {
Text("Compact")
} else {
Text("Regular")
}
}
}
Yeah, forgot about the NavigationViews.
You could try something like this (using the code from "https://matteo-puccinelli.medium.com/conditionally-apply-modifiers-in-swiftui-51c1cf7f61d1")
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
extension View {
#ViewBuilder
func ifCondition<TrueContent: View, FalseContent: View>(_ condition: Bool, then trueContent: (Self) -> TrueContent, else falseContent: (Self) -> FalseContent) -> some View {
if condition {
trueContent(self)
} else {
falseContent(self)
}
}
}
struct ContentView: View {
#Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
NavigationView {
if sizeClass == .compact {
Text("Compact size class inside stack navigation style")
} else {
Text("Regular size class inside default navigation style")
}
}
.ifCondition(sizeClass == .compact) { nv in
nv.navigationViewStyle(StackNavigationViewStyle())
} else: { nv in
nv.navigationViewStyle(DefaultNavigationViewStyle())
}
}
}

SwiftUI get EnvironmentValues of some View given only a reference to the view

According to my understanding, if you define a view yourself (as a struct that implements View), then you can declare some var to be an Environment variable, like this:
#Environment(\.isEnabled) var isEnabled
This will give you access to the EnvironmentValues.isEnabled field.
However, it seems like this is only possible within the view definition itself.
Is it possible, given some view v, to get the environment object of that view? or get specific environment values?
I assume that taking into account that SwiftUI is reactive state managed framework then yes, directly you cannot ask for internal view environment value, actually because you can't be sure when that environment is set up for this specific view. However you can ask that view to tell you about its internal state, when view is definitely knowns about it... via binding.
An example code (a weird a bit, but just for demo) how this can be done see below.
struct SampleView: View {
#Environment(\.isEnabled) private var isEnabled
var myState: Binding<Bool>?
var body: some View {
VStack {
Button(action: {}) { Text("I'm \(isEnabled ? "Enabled" : "Disabled")") }
report()
}
}
func report() -> some View {
DispatchQueue.main.async {
self.myState?.wrappedValue = self.isEnabled
}
return EmptyView()
}
}
struct TestEnvironmentVar: View {
#State private var isDisabled = false
#State private var sampleState = true
var body: some View {
VStack {
Button(action: {
self.isDisabled.toggle()
}) {
Text("Toggle")
}
Divider()
sampleView()
.disabled(isDisabled)
}
}
private func sampleView() -> some View {
VStack {
SampleView(myState: $sampleState)
Text("Sample is \(sampleState ? "Enabled" : "Disabled")")
}
}
}
struct TestEnvironmentVar_Previews: PreviewProvider {
static var previews: some View {
TestEnvironmentVar()
}
}