Why doesn't custom SwiftUI view animate on ViewModel state change? - ios

I am presenting a custom view when my viewModel changes to an error state. I want to animate this presentation. I represent the viewModel state using an enum
enum ViewModelState<T> {
case idle
case error(Error)
case loading
case data(T)
var isError: Bool {
switch self {
case .error:
return true
default:
return false
}
}
}
In my viewModel I have this property...
#Published var state: ViewModelState = .idle
When it changes to an error state I want to animate in my error view. When I do the following with an if statement in my view it animates in...
private var content: some View {
return ZStack {
mainView // VStack
if case let .error(error) = viewModel.state {
ErrorView(
errorDescription: error.localizedDescription,
state: $viewModel.state
)
}
}.animation(.default)
}
var body: some View {
content
.navigationBarTitle(viewModel.title)
.navigationBarBackButtonHidden(true)
}
However I want to switch on the error and do this
private var content: some View {
switch viewModel.state {
case .idle:
return mainView.eraseToAnyView()
case let .error(error):
return ZStack {
mainView
ErrorView(
errorDescription: error.localizedDescription,
state: $viewModel.state
)
}.animation(.default).eraseToAnyView()
default:
return EmptyView().eraseToAnyView()
}
}
I don't understand why this approach doesn't animate - is there a way to animate views without resorting to multiple properties in my view model to represent state/computed properties off my state enum or is this the best approach?

AnyView hides all implementation details from the compiler. That means that SiwftUI can't animate, because it tries to infer all animations during compilation.
That's why you should try to avoid AnyView whenever possible.
If you want to seperate some logic of your body into a computed property (or function), use the #ViewBuilder annotation.
#ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle:
mainView
case let .error(error):
ZStack {
mainView
ErrorView(
errorDescription: error.localizedDescription,
state: $viewModel.state
)
}.animation(.default)
default:
EmptyView()
}
}
Notice that I removed return in the property. The #ViewBuilder behaves much like the default body property by wrapping everything in a Group.
The second problem are the states between you animate.
By using the switch you are creating an SwiftUI._ConditionalContent<MainView,SwiftUI.ZStack<SwiftUI.TupleView<(MainView, ErrorView)>>
That is a little hard to parse, but you creating a ConditionalContent that switches between just the MainView and an ZStack consisting of your MainView and the ErrorView, which is not what we want.
We should try to create a ZStack with out MainView and an optional ErrorView.
What I like to to is to extend by view models with a computed error: Error? property.
extension ViewModel {
var error: Error? {
guard case let .error(error) = state else {
return nil
}
return error
}
}
Now, you can create an optional view, by using the map function of the Optional type
ZStack {
mainView
viewModel.error.map { unwrapped in
ErrorView(
errorDescription: unwrapped.localizedDescription,
state: $viewModel.state
)
}
}
That should work as expected.

Related

NavigationStack inside TabView inside NavigationStack throws

I'm trying to migrate a coordinator pattern using UINavigationController into the new NavigationStack.
The navigation flow is quite complex, but I've made a simple project to simplify it to this:
NavigationStack
-> MainScreen
-> TabView
-> FirstTab
-> NavigationStack
-> FirstTabFirst
-> FirstTabSecond
-> SecondTab
-> SecondTabScreen
-> Second Top Screen
Althoug the first Screens in each NavigatorStack are actually the root view of the navigator, it shouldn't be a problem as I can't even get it to navigate to something.
All the navigators have their state defined by a #StateObject to allow for both programatic imperative navigation and NavigationLink, similar to what a Coordinator pattern provides.
Upon launching the app, it immediatelly throws in the #main line, with no further information about the call stack:
Thread 1: Fatal error: 'try!' expression unexpectedly raised an error: SwiftUI.AnyNavigationPath.Error.comparisonTypeMismatch
This is the code for the whole app:
import SwiftUI
protocol NavigationRoute: Hashable {
associatedtype V: View
#ViewBuilder
func view() -> V
}
enum Top: NavigationRoute {
case first, second
#ViewBuilder
func view() -> some View {
switch self {
case .first: TopFirst()
case .second: TopSecond()
}
}
}
enum Tab: NavigationRoute {
case first, second
#ViewBuilder
func view() -> some View {
switch self {
case .first: TabFirst()
case .second: TabSecond()
}
}
var label: String {
switch self {
case .first: return "First"
case .second: return "Second"
}
}
var systemImage: String {
switch self {
case .first: return "house"
case .second: return "person.fill"
}
}
#ViewBuilder
func tab() -> some View {
view()
.tabItem { Label(label, systemImage: systemImage) }
.tag(self)
}
}
enum FirstTab: NavigationRoute {
case first, second
#ViewBuilder
func view() -> some View {
switch self {
case .first: FirstTabFirst()
case .second: FirstTabSecond()
}
}
}
class StackNavigator<Route: NavigationRoute>: ObservableObject {
#Published var routes: [Route] = []
}
class TabNavigator<Route: NavigationRoute>: ObservableObject {
#Published public var tab: Route
public init(initial: Route) {
tab = initial
}
public func navigate(_ route: Route) {
tab = route
}
}
#main
struct NavigationStackTestApp: App {
#StateObject var navigator = StackNavigator<Top>()
var body: some Scene {
WindowGroup {
NavigationStack(path: $navigator.routes) {
TopFirst().navigationDestination(for: Top.self) {
$0.view()
}
}
}
}
}
struct TopFirst: View {
#StateObject var navigator = TabNavigator<Tab>(initial: .first)
var body: some View {
TabView(selection: $navigator.tab) {
Tab.first.tab()
Tab.second.tab()
}
}
}
struct TopSecond: View {
var body: some View {
Text("Top Second")
}
}
struct TabFirst: View {
#StateObject var navigator = StackNavigator<FirstTab>()
var body: some View {
NavigationStack(path: $navigator.routes) {
FirstTabFirst().navigationDestination(for: FirstTab.self) {
$0.view()
}
}
}
}
struct TabSecond: View {
var body: some View {
Text("Tab Second")
}
}
struct FirstTabFirst: View {
var body: some View {
Text("First Tab First")
}
}
struct FirstTabSecond: View {
var body: some View {
Text("First Tab Second")
}
}
To avoid it from crashing I have to replace the NavigationStack of the first tab with an EmptyView(), that is changing:
case .first: TabFirst()
to
case .first: EmptyView()
But of course after doing that the first tab is missing and the app doesn't do what it's supposed to.
Has anyone encountered something like this or has an idea of how to get this working? SwiftUI is closed source and it's documentation is very limited so it's really hard to know what's really going on under the hood.
EDIT:
Using NavigationPath as state for StackNavigator doesn't crash the app, but any navigation in the first tab's NavigationStack pushes a new view in the top navigator and hides the bottom tab bar.
I'm assuming you just can't have two navigation stacks in the same hierarchy and expect it to work as you would assume.
Using two NavigationStacks in the same hierarchy doesn't work very well as their internal states collide, I would not recommend it and it seems to cause the error. The error is thrown when the lower navigator is declared because it's declared type is also handled by the top navigator, which doesn't have a handler declared for the lower navigator's tap in its navigationDestination.
It is better to use conditional rendering and manual animation/transitions in the upper level and then from there render only one NavigationStack per hierarchy. There is no issue using NavigationStack inside TabView as long as that TabView is not itself inside a NavigationStack.
NavigationPath can be used to support multiple unrelated types in the state stack.

iOS 16 Binding issue (in this case Toggle)

What i have stumped across is that when i pass a Binding<> as a parameter in a ViewModel as you will see below, it doesn't always work as intended. It works in iOS 15 but not in iOS 16. This reduces the capability to create subviews to handle this without sending the entire Holder object as a #Binding down to it's subview, which i would say would be bad practice.
So i have tried to minimize this as much as possible and here is my leasy amount of code to reproduce it:
import SwiftUI
enum Loadable<T> {
case loaded(T)
case notRequested
}
struct SuperBinder {
let loadable: Binding<Loadable<ContentView.ViewModel>>
let toggler: Binding<Bool>
}
struct Holder {
var loadable: Loadable<ContentView.ViewModel> = .notRequested
var toggler: Bool = false
func generateContent(superBinder: SuperBinder) {
superBinder.loadable.wrappedValue = .loaded(.init(toggleBinder: superBinder.toggler))
}
}
struct ContentView: View {
#State var holder = Holder()
var superBinder: SuperBinder {
.init(loadable: $holder.loadable, toggler: $holder.toggler)
}
var body: some View {
switch holder.loadable {
case .notRequested:
Text("")
.onAppear(perform: {
holder.generateContent(superBinder: superBinder)
})
case .loaded(let viewModel):
Toggle("testA", isOn: viewModel.toggleBinder) // Works but doesn't update UI
Toggle("testB", isOn: $holder.toggler) // Works completly
// Pressing TestA will even toggle TestB
// TestB can be turned of directly but has to be pressed twice to turn on. Or something?
}
}
struct ViewModel {
let toggleBinder: Binding<Bool>
}
}
anyone else stumbled across the same problem? Or do i not understand how binders work?

How to keep SwiftUI from creating additional StateObjects in this custom page view?

Abstract
I'm creating an app that allows for content creation and display. The UX I yearn for requires the content creation view to use programmatic navigation. I aim at architecture with a main view model and an additional one for the content creation view. The problem is, the content creation view model does not work as I expected in this specific example.
Code structure
Please note that this is a minimal reproducible example.
Suppose there is a ContentView: View with a nested AddContentPresenterView: View. The nested view consists of two phases:
specifying object's name
summary screen
To allow for programmatic navigation with NavigationStack (new in iOS 16), each phase has an associated value.
Assume that AddContentPresenterView requires the view model. No workarounds with #State will do - I desire to learn how to handle ObservableObject in this case.
Code
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
VStack {
NavigationStack(path: $model.path) {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
AddContentPresenterView(page: page)
}
}
Button {
model.navigateToNextPartOfContentCreation()
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
ContentDetailView (irrelevant)
struct ContentDetailView: View {
let content: Content
var body: some View {
Text(content.name)
}
}
AddContentPresenterView
As navigationDestination associates a destination view with a presented data type for use within a navigation stack, I found no better way of adding a paged view to be navigated using the NavigationStack than this.
extension AddContentPresenterView {
var contentName: some View {
TextField("Name your content", text: $addContentViewModel.contentName)
.onSubmit {
model.navigateToNextPartOfContentCreation()
}
}
var contentSummary: some View {
VStack {
Text(addContentViewModel.contentName)
Button {
model.addContent(addContentViewModel.createContent())
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
ContentViewViewModel
Controls the navigation and adding content.
class ContentViewViewModel: ObservableObject {
#Published var path = NavigationPath()
#Published var content: [Content] = []
func navigateToNextPartOfContentCreation() {
switch path.count {
case 0:
path.append(Page.contentName)
case 1:
path.append(Page.contentSummary)
default:
fatalError("Navigation error.")
}
}
func navigateToRoot() {
path.removeLast(path.count)
}
func addContent(_ content: Content) {
self.content.append(content)
}
}
AddContentViewModel
Manages content creation.
class AddContentViewModel: ObservableObject {
#Published var contentName = ""
func createContent() -> Content {
return Content(name: contentName)
}
}
Page
Enum containing creation screen pages.
enum Page: Hashable {
case contentName, contentSummary
}
What is wrong
Currently, for each page pushed onto the navigation stack, a new StateObject is created. That makes the creation of object impossible, since the addContentViewModel.contentName holds value only for the bound screen.
I thought that, since StateObject is tied to the view's lifecycle, it's tied to AddContentPresenterView and, therefore, I would be able to share it.
What I've tried
The error is resolved when addContentViewModel in AddContentPresenterView is an EnvironmentObject initialized in App itself. Then, however, it's tied to the App's lifecycle and subsequent content creations greet us with stale data - as it should be.
Wraping up
How to keep SwiftUI from creating additional StateObjects in this custom page view?
Should I resort to ObservedObject and try some wizardry? Should I just implement a reset method for my AddContentViewModel and reset the data on entering or quiting the screen?
Or maybe there is a better way of achieving what I've summarized in abstract?
If you declare #StateObject var addContentViewModel = AddContentViewModel() in your AddContentPresenterView it will always initialise new AddContentViewModel object when you add AddContentPresenterView in navigation stack. Now looking at your code and app flow I don't fill you need AddContentViewModel.
First, update your contentSummary of the Page enum with an associated value like this.
enum Page {
case contentName, contentSummary(String)
}
Now update your navigate to the next page method of your ContentViewModel like below.
func navigateToNextPage(_ page: Page) {
path.append(page)
}
Now for ContentView, I think you need to add VStack inside NavigationStack otherwise that bottom plus button will always be visible.
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
NavigationStack(path: $model.path) {
VStack {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
switch page {
case .contentName: AddContentView()
case .contentSummary(let name): ContentSummaryView(contentName: name)
}
}
Button {
model.navigateToNextPage(.contentName)
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
}
So now it will push destination view on basis of the type of the Page. So you can remove your AddContentPresenterView and add AddContentView and ContentSummaryView.
AddContentView
struct AddContentView: View {
#EnvironmentObject var model: ContentViewViewModel
#State private var contentName = ""
var body: some View {
TextField("Name your content", text: $contentName)
.onSubmit {
model.navigateToNextPage(.contentSummary(contentName))
}
}
}
ContentSummaryView
struct ContentSummaryView: View {
#EnvironmentObject var model: ContentViewViewModel
let contentName: String
var body: some View {
VStack {
Text(contentName)
Button {
model.addContent(Content(name: contentName))
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
So as you can see I have used #State property in AddContentView to bind it with TextField and on submit I'm passing it as an associated value with contentSummary. So this will reduce the use of AddContentViewModel. So now there is no need to reset anything or you want face any issue of data loss when you push to ContentSummaryView.

Cell in List with LazyVGrid Disappears sometimes

I have this:
VStack {
List {
LazyVGrid(columns: gridItemLayout) {
ForEach(viewModel.objects, id: \.fileGroupUUID) { item in
AlbumItemsScreenCell(object: item, viewModel: viewModel, config: Self.config)
.onTapGesture {
switch viewModel.changeMode {
case .moving, .sharing, .moveAll:
viewModel.toggleItemToChange(item: item)
case .none:
object = item
viewModel.showCellDetails = true
}
}
.onLongPressGesture {
viewModel.restartDownload(fileGroupUUID: item.fileGroupUUID)
}
} // end ForEach
} // end LazyVGrid
}
.listStyle(PlainListStyle())
.refreshable {
viewModel.refresh()
}
.padding(5)
// Mostly this is to animate updates from the menu. E.g., the sorting order.
.animation(.easeInOut)
// Had a problem with return animation for a while: https://stackoverflow.com/questions/65101561
// The solution was to take the NavigationLink out of the scrollview/LazyVGrid above.
if let object = object {
// The `NavigationLink` works here because the `MenuNavBar` contains a `NavigationView`.
NavigationLink(
destination:
ObjectDetailsView(object: object, model: ObjectDetailsModel(object: object)),
isActive:
$viewModel.showCellDetails) {
EmptyView()
}
.frame(width: 0, height: 0)
.disabled(true)
} // end if
} // end VStack
AlbumItemsScreenCell:
struct AlbumItemsScreenCell: View {
#StateObject var object:ServerObjectModel
#StateObject var viewModel:AlbumItemsViewModel
let config: IconConfig
#Environment(\.colorScheme) var colorScheme
var body: some View {
AnyIcon(model: AnyIconModel(object: object), config: config,
emptyUpperRightView: viewModel.changeMode == .none,
upperRightView: {
UpperRightChangeIcon(object: object, viewModel: viewModel)
})
}
}
When a user taps one of the cells, this causes navigation to a details screen. Sometimes when the user returns from that navigation, the cell in the upper left disappears:
https://www.dropbox.com/s/mi6j2ie7h8dcdm0/disappearingCell.mp4?dl=0
My current hypothesis about the issue is that when user actions in that details screen take actions which change viewModel.objects, this causes the disappearing cell problem. I'll be testing this hypothesis shortly.
----- Update, 11/1/21 ------
Well, that hypothesis was wrong. I now understand the structure of the problem more clearly. Still don't have a fix though.
Tapping on one of the AlbumItemsScreenCells navigates to a details screen (I've added to the code above to show that). In the details screen user actions can cause a comment count to get reset, which sends a Notification.
A model in the AlbumItemsScreenCell listens for these notification (for the specific cell) and resets a badge on the cell.
Here is that model:
class AnyIconModel: ObservableObject, CommentCountsObserverDelegate, MediaItemBadgeObserverDelegate, NewItemBadgeObserverDelegate {
#Published var mediaItemBadge: MediaItemBadge?
#Published var unreadCountBadgeText: String?
#Published var newItem: Bool = false
var mediaItemCommentCount:CommentCountsObserver!
let object: ServerObjectModel
var mediaItemBadgeObserver: MediaItemBadgeObserver!
var newItemObserver: NewItemBadgeObserver!
init(object: ServerObjectModel) {
self.object = object
// This is causing https://stackoverflow.com/questions/69783232/cell-in-list-with-lazyvgrid-disappears-sometimes
mediaItemCommentCount = CommentCountsObserver(object: object, delegate: self)
mediaItemBadgeObserver = MediaItemBadgeObserver(object: object, delegate: self)
newItemObserver = NewItemBadgeObserver(object: object, delegate: self)
}
}
The unreadCountBadgeText gets changed (on the main thread) by the observer when the Notification is received.
So, in summary, the badge on the cell gets changed while the screen with the cells is not displayed-- the details screen is displayed.
I had been using the following conditional modifier:
extension View {
public func enabled(_ enabled: Bool) -> some View {
return self.disabled(!enabled)
}
// https://forums.swift.org/t/conditionally-apply-modifier-in-swiftui/32815/16
#ViewBuilder func `if`<T>(_ condition: Bool, transform: (Self) -> T) -> some View where T : View {
if condition {
transform(self)
} else {
self
}
}
}
to display the badge on the AlbumItemsScreenCell.
The original badge looked like this:
extension View {
func upperLeftBadge(_ badgeText: String) -> some View {
return self.modifier(UpperLeftBadge(badgeText))
}
}
struct UpperLeftBadge: ViewModifier {
let badgeText: String
init(_ badgeText: String) {
self.badgeText = badgeText
}
func body(content: Content) -> some View {
content
.overlay(
ZStack {
Badge(badgeText)
}
.padding([.top, .leading], 5),
alignment: .topLeading
)
}
}
i.e., the usage looked like this in the cell:
.if(condition) {
$0.upperLeftBadge(badgeText)
}
when I changed the modifier to use it with out this .if modifier, and used it directly, the issue went away:
extension View {
func upperLeftBadge(_ badgeText: String?) -> some View {
return self.modifier(UpperLeftBadge(badgeText: badgeText))
}
}
struct UpperLeftBadge: ViewModifier {
let badgeText: String?
func body(content: Content) -> some View {
content
.overlay(
ZStack {
if let badgeText = badgeText {
Badge(badgeText)
}
}
.padding([.top, .leading], 5),
alignment: .topLeading
)
}
}
It may sounds weird, but in my tvOS app i disable the animations
and cells(Views) stops to disappear
So try to remove .animation(.easeInOut)

Showing and hiding views in SwiftUI

I want to show a view while tasks complete, how else could I call a SwiftView to show without using conditionals based on a State bool? Basically I want to avoid having this hardcoded below for every file that is gonna need the loading view...
struct ContentView: View {
#State var doIWantThisViewToShow: Bool = false
var body: some View {
VStack {
Button("Show/Hide MyView") {
doIWantThisViewToShow.toggle()
}
if doIWantThisViewToShow {
MyView()
.padding()
}
}
}
}
You can explore a few strategies to reduce duplicate code.
Custom Environment value to avoid passing #Binding
Reusable effects
Maybe a protocol if your state is complex (probably not)
Mutating a view hierarchy
A custom EnvironmentValue broadcasts a state down to child views. This will save you from passing #Binding through views that may not consume the value.
Keep in mind this is a one-way top-down broadcast. Unlike #Binding, children can't mutate parent state. (But they can mutate their own children's knowledge of said state.)
Set in Parent
#State var isHovered = false
var parent: some View {
///
.environment(\.parentIsHovered, isHovered)
}
Observe in Child
#Environment(\.parentIsHovered) var parentIsHovered
var child: some View {
///
.grayscale(parentIsHovered ? 0 : 0.9)
.animation(.easeInOut, value: parentIsHovered)
}
Define
private struct ParentIsHoveredKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var parentIsHovered: Bool {
get { return self[ParentIsHoveredKey] }
set { self[ParentIsHoveredKey] = newValue }
}
}
Reusable effects
If you gray out certain views or display a loading indicator, you can use a ViewModifier that accepts a Binding and conditionally displays an overlay, background or effects.
The example below demonstrates that by linking the .animation API to accessibilityReduceMotion.
// view
.animate(.easeOut(duration: .fast), value: isLoading)
extension View {
func animate<E: Equatable>(_ animation: Animation?, value: E) -> some View {
self.modifier(AccessibleAnimationModifier(animation, for: value))
}
}
struct AccessibleAnimationModifier<E: Equatable>: ViewModifier {
#Environment(\.accessibilityReduceMotion) var reduceMotion
init(_ animation: Animation? = .default, for value: E) {
self.animation = animation
self.value = value
}
var animation: Animation?
var value: E
func body(content: Content) -> some View {
content
.animation(reduceMotion ? .none : animation, value: value)
}
}
Reacting to loading state
Unless you handle loading state through some observed class, you need to store that state in your View using #State.
Maybe a protocol with a default implementation in an extension helps reduce duplicate code in computing a complex loading state between Views.
The pseudocode below defines a protocol DragSource with functions returning an NSItemProvider. The extension provides default implementations that a View or VM can call.
protocol DragSource {
func makeDraggableThing1(/// content + logic objects) -> NSItemProvider
}
extension DragSource {
func makeDraggableThing1(///) -> NSItemProvider {
/// Default work I only want to edit once in the app
}
}

Resources