View not updating during a transition - ios

I need your help to understand an issue I'm having. I can't manage to make a view redraws its body just before a transition animation. Take a look at this simple example:
import SwiftUI
struct ContentView: View {
#State private var condition = true
#State private var fgColor = Color.black
var body: some View {
VStack {
Group {
if condition {
Text("Hello")
.foregroundColor(fgColor)
} else {
Text("World")
}
}
.transition(.slide)
.animation(.easeOut(duration: 3))
Button("TAP") {
fgColor = .red
condition.toggle()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
What I expected from this example was: when I tap on the button the view creates its body again and the text "Hello" becomes red. Now, the view creates its body another time and the transition happens. Instead, it seems that SwiftUI merges the two state changes somehow and only the second one is considered. The result is that the transition happens, but the text "Hello" won't change its color.
How can I manage a situation like this in SwiftUI? Is there a way to tell the framework to update the two state changes separately? Thank you.
EDIT for #Asperi:
I tried your code but it doesn't work. The result is still the same. This is the complete example with your code:
import SwiftUI
struct ContentView: View {
#State private var condition = true
#State private var fgColor = Color.black
var body: some View {
VStack {
VStack {
if condition {
Text("Hello")
.foregroundColor(fgColor)
.transition(.slide)
} else {
Text("World")
.transition(.slide)
}
}
.animation(.easeOut(duration: 3))
Button("TAP") {
fgColor = .red
condition.toggle()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
And this is the result (on iPhone 12 Mini iOS 14.1):

It's impossible to do what you want with just modifiers.
Because when change the view state, the code block with Text("Hello") doesn't get's called, and that's the reason it starts disappearing with transition you've specifier(because this view is missing in the view hierarchy)
So the best way you can do it is implementing your custom transition. At first you need to create a ViewModifier with your desired behavior:
struct ForegroundColorModifier: ViewModifier {
var foregroundColor: Color
func body(content: Content) -> some View {
content
// for some reason foregroundColor for text is not animatable by itself, but can animate with colorMultiply
.foregroundColor(.white)
.colorMultiply(foregroundColor)
}
}
Then create a Transition using this modifier - here you need to specify modifiers for two states:
extension AnyTransition {
static func foregroundColor(active: Color, identity: Color) -> AnyTransition {
AnyTransition.modifier(
active: ForegroundColorModifier(foregroundColor: active),
identity: ForegroundColorModifier(foregroundColor: identity)
)
}
}
Combine color transition with a slide one:
.transition(
AnyTransition.slide.combined(
with: .foregroundColor(
active: .red,
identity: .black
)
)
)
Finally If you need color change only on disappearing, use asymmetric transition:
.transition(
.asymmetric(
insertion: .slide,
removal: AnyTransition.slide.combined(
with: .foregroundColor(
active: .red,
identity: .black
)
)
)
)
Full code:
struct ContentView: View {
#State private var condition = true
var body: some View {
VStack {
Group {
if condition {
Text("Hello")
} else {
Text("World")
}
}
.transition(
.asymmetric(
insertion: .slide,
removal: AnyTransition.slide.combined(
with: .foregroundColor(
active: .red,
identity: .black
)
)
)
)
Button("TAP") {
withAnimation(.easeOut(duration: 3)) {
condition.toggle()
}
}
}
}
}
I'm not sure why specifying .animation after .transition doesn't work in this case, but changing state inside withAnimation block works as expected

Here is a possible solution:
struct ContentView: View {
#State private var condition = true
#State private var fgColor = Color.black
var body: some View {
VStack {
Group {
if condition {
Text("Hello")
.foregroundColor(fgColor)
} else {
Text("World")
}
}
.transition(.slide)
.animation(.easeOut(duration: 3))
Button("TAP") {
fgColor = .red
withAnimation {
condition.toggle()
}
}
}
}
}
The idea here is that you propagate the transition that was caused by condition change by changing it inside withAnimation (which is delayed compared to the foreground color change with no animation).

The main issue is in using Group - it is not a container, instead use some real container and apply transitions to views directly, like
var body: some View {
VStack {
VStack {
if condition {
Text("Hello")
.foregroundColor(fgColor)
.transition(.slide)
} else {
Text("World")
.transition(.slide)
}
}
.animation(.easeOut(duration: 3))
Button("TAP") {
fgColor = .red
condition.toggle()
}
}
}

The most easier and logical way of making your code work, updating View before Animation take the control.
Version 1.0.0:
import SwiftUI
struct ContentView: View {
#State private var condition = true
#State private var fgColor = Color.black
var body: some View {
VStack(spacing: 40.0) {
Group {
if condition {
Text("Hello")
.foregroundColor(fgColor)
}
else {
Text("World")
}
}
.transition(.slide)
.animation(.easeOut(duration: 3))
Button("TAP") {
fgColor = .red
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(50)) { condition.toggle() }
}
}
}
}
Version 2.0.0:
import SwiftUI
struct ContentView: View {
#State private var condition: Bool = true
#State private var fgColor: Color = Color.green
#State private var startTransition: Bool = Bool()
var body: some View {
VStack(spacing: 40.0) {
Group {
if condition {
Text("Hello")
.foregroundColor(fgColor)
}
else {
Text("World")
}
}
.transition(.slide)
.animation(.easeOut(duration: 3))
.onChange(of: startTransition) { _ in condition.toggle() }
Button("TAP") {
if fgColor == Color.red { fgColor = Color.green } else { fgColor = Color.red }
startTransition.toggle()
}
}
}
}

Try this,
struct ContentView: View {
#State private var condition = true
#State private var fgColor = Color.black
var body: some View {
VStack {
HStack {
Text("Hello")
.foregroundColor(fgColor)
if !condition {
Text("World")
}
}
.transition(.slide)
.animation(.easeOut(duration: 3))
Button("TAP") {
fgColor = .red
condition.toggle()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Related

Why does withAnimation not cause an animation?

I try to switch from one screen to another by pressing a button (see full code below). The switch from the first view to the second view (and vice versa) works, but no animation is taking place.
Why does this behavior happen?
Full code:
struct ContentView: View {
#AppStorage("firstViewActive") var isFirstViewActive: Bool = true
var body: some View {
if isFirstViewActive {
FirstView()
} else {
SecondView()
}
}
}
struct FirstView: View {
#AppStorage("firstViewActive") var isFirstViewActive: Bool = true
var body: some View {
ZStack {
Color(.red).ignoresSafeArea(.all, edges: .all)
VStack {
Spacer()
Text("This is the first view")
Spacer()
Button {
withAnimation {
isFirstViewActive = false
}
} label: {
Text("Go to second view")
}
Spacer()
}
}
}
}
struct SecondView: View {
#AppStorage("firstViewActive") var isFirstViewActive: Bool = false
var body: some View {
ZStack {
Color(.blue).ignoresSafeArea(.all, edges: .all)
VStack {
Spacer()
Text("This is the second view")
Spacer()
Button {
withAnimation {
isFirstViewActive = true
}
} label: {
Text("Go to first view")
}
Spacer()
}
}
}
}
The problem is with
#AppStorage("firstViewActive") var isFirstViewActive: Bool = true
If you change that to
#State var isFirstViewActive: Bool = true
and use #Binding in subviews, you will get the default animations.
In iOS 16, there seems to be a problem with #AppStorage vars and animation. But you can refer to this workaround

SwiftUI & Mac Catalyst | Adding buttons to trailing navigation bar

I have a Mac Catalyst app I had built using SwiftUI,and I cant seem to add buttons to the trailing navigation bar?
I am also unsure where this navigationBar is defined, is it possible to remove? It only seems to have appeared in Ventura.
struct AppSidebarNavigation: View {
enum NavigationItem {
case home
}
#State private var selection: NavigationItem? = .home
init() {
#if !targetEnvironment(macCatalyst)
UITableView.appearance().backgroundColor = UIColor(named: "White")
UITableViewCell.appearance().selectionStyle = .none
UITableView.appearance().allowsSelection = false
#endif
}
var body: some View {
NavigationView {
sidebar
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
// Main View
HomeView()
.navigationTitle("")
.edgesIgnoringSafeArea(.top)
.navigationBarHidden(isMac ? false : true)
.navigationBarBackButtonHidden(isMac ? false : true)
}
.accentColor(Color("Black"))
.navigationViewStyle(.columns)
}
}
HomeView I had the following to the View.
#if targetEnvironment(macCatalyst)
.navigationBarItems(trailing:
NavButtons
)
#endif
var NavButtons: some View {
HStack {
Button(action: {
Print("No")
}) {
Image(systemName: "plus")
.font(.system(size: 14, weight: .medium))
}
.buttonStyle(NavPlusButton())
}
}
I don't think it is possible to do that because:
#available(iOS, introduced: 13.0, deprecated: 100000.0, message: "Use toolbar(_:) with navigationBarLeading or navigationBarTrailing placement")
#available(tvOS, introduced: 13.0, deprecated: 100000.0, message: "Use toolbar(_:) with navigationBarLeading or navigationBarTrailing placement")
#available(macOS, unavailable)
#available(watchOS, unavailable)
public func navigationBarItems<T>(trailing: T) -> some View where T : View
This indicates on macOS the function in not available.
I slightly modified your code (to get it to compile) and saw that where it would be is at the red circle below:
My code is:
struct AppSidebarNavigation: View {
enum NavigationItem {
case home
}
#State private var selection: NavigationItem? = .home
var isMac: Bool
init() {
#if !targetEnvironment(macCatalyst)
UITableView.appearance().backgroundColor = UIColor(named: "White")
UITableViewCell.appearance().selectionStyle = .none
UITableView.appearance().allowsSelection = false
isMac = false
#else
isMac = true
#endif
}
var body: some View {
NavigationView {
// Main View
HomeView()
.navigationTitle("NavTitle")
.edgesIgnoringSafeArea(.top)
.navigationBarHidden(isMac ? false : true)
.navigationBarBackButtonHidden(isMac ? false : true)
.navigationBarItems(trailing: NavButtons)
}
.accentColor(Color("Black"))
.navigationViewStyle(.columns)
}
}
var NavButtons: some View {
HStack {
Button(action: {}) {
Image(systemName: "plus")
}
// Button(action: {
// print("No")
// }) {
// Image(systemName: "plus")
// .font(.system(size: 14, weight: .medium)).frame(width: 80)
// }
}
}
struct HomeView: View {
var body: some View {
Group {
Text("Home View")
NavButtons
}
}
}
struct HomeView_Previews: PreviewProvider {
static var previews: some View {
HomeView()
}
}
struct NavButton_Previews: PreviewProvider {
static var previews: some View {
NavButtons
}
}
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
AppSidebarNavigation()
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can use Toolbar modifier with .primaryAction as placement to place button on the trailing side on Navigation Bar.
Just replace .navigationBarItem with below:
#if targetEnvironment(macCatalyst)
.toolbar {
ToolbarItem(placement: .primaryAction) {
NavButtons
}
}
#endif

SwiftUI increasing sheet size after NavigationLink with the "selection" in "presentationDetents" cuts off view inside the sheet

Since iOS 16 there is a new feature for the ".sheet" modifier called ".presentationDetents". ".presentationDetents" has a parameter called "selection" where you can pass a Binding. You can programmatically resize the sheet with the "selection" parameter. As soon as you change the sheet size for example from PresentationDetent.medium to PresentationDetent.large right after changed the page with a "NavigationLink" the View below gets cut off:
But if I slightly move (resize) the sheet afterwards the cut off below is going to disappear:
The view hierarchy is also strange:
If you add a delay by 0.6s for resizing the sheet, the cut off won't happen.
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
currentSelection = .large
}
}
You can find the code below:
import SwiftUI
struct ContentView: View {
#State private var sheetIsOpened = false
#State private var currentSelection = PresentationDetent.medium
var body: some View {
Text("Click to open a sheet")
.padding()
.onTapGesture {
sheetIsOpened = true
}
.sheet(isPresented: $sheetIsOpened) {
NavigationStack {
List {
ScrollView {
ForEach(0..<100) { index in
VStack {
NavigationLink(destination: NavigatedView(currentSelection: $currentSelection)) {
Text("I have the index: \(index)")
.foregroundColor(.green)
}
}
.frame(maxWidth: .infinity)
}
}
.padding()
}
}
.presentationDetents([.medium, .large], selection: $currentSelection)
}
}
}
struct NavigatedView: View {
#Binding fileprivate var currentSelection: PresentationDetent
var body: some View {
ScrollView {
ForEach(0..<100) { index in
VStack {
Text("I'm a child and I have the index: \(index)")
.onAppear {
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
currentSelection = .large
// }
}
}
.frame(maxWidth: .infinity)
}
}
.background(.red)
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

List moves down after the app has been in the background

So here's my problem:
I have a main view with a navigation view and then the list view.
The colors are just to distinguish different views.
At first everything works as intended but as soon as the app goes into the background (aka Home Screen) and then back to active again my list view is bugging out.
The whole list moves down roughly 3 times the height of one cell.
The main views background is blue so it's not the whole list which gets moved.
I tried to use PlainListStyle() and GroupedListStyle() but the header as well moves down.
If I use one of the links in the bottom blue bar (yeah I know, dumb choice of color and changed in the code snippet below) and then navigate back the list is back in it's original shape.
Edit:
Adding anything to the Stack above the list fixes the problem.
A text or a rectangle is all it takes.
As a workaround a rectangle with heigh 0.1 and the same color as the navigation bar is sufficient.
But any ideas to fix it permanently would be appreciated!
What it should look like:
What it looks like after the app has been in the background:
import SwiftUI
struct TestListView: View
{
#State var dummyList: [DummyCell] = []
#Environment(\.scenePhase) var scenePhase
#State private var listBlur : CGFloat = 0.0
var body: some View
{
List
{
ForEach(dummyList)
{ item in
item
.listRowBackground(Color.yellow)
}
.onDelete
{ indexSet in
dummyList.remove(atOffsets: indexSet)
}
.onMove
{ indices, newOffsets in
dummyList.move(fromOffsets: indices, toOffset: newOffsets)
}
}
.background
{
Color.green
}
.onAppear
{
if dummyList.isEmpty
{
let testData = testData()
testData.forEach
{
daten in
dummyList.append(daten)
}
}
}
.onDisappear { print("List OnDisappear") }
.blur(radius: listBlur)
.onChange(of: scenePhase)
{
newPhase in
if newPhase == .active
{
print("List Active")
dummyList.removeAll()
let list = testData()
list.forEach { cell in
dummyList.append(cell)
}
listBlur = 0
}
else if newPhase == .inactive
{
print("List Inactive")
listBlur = 10.0
}
}
}
private func testData() -> [DummyCell]
{
var list : [DummyCell] = []
for index in 0..<20
{
list.append(DummyCell(dummyNumber: index))
}
return list
}
}
struct TestListView_Previews: PreviewProvider {
static var previews: some View {
TestListView()
}
}
import SwiftUI
struct MainView: View {
#Environment(\.scenePhase) var scenePhase
var body: some View {
ZStack
{
NavigationView
{
VStack
{
// Main View
TestListView()
.navigationBarTitle("listTest", displayMode: .inline)
.navigationBarItems(leading: EditButton())
.navigationBarItems(trailing: EditButton())
// Footer
Footer()
}
.background
{
Color.green
}
}
.navigationBarColor(UIColor(Color.orange))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}
struct Footer: View
{
var body: some View
{
HStack
{
NavigationLink(destination: Text("SomeView"), label: {
Text("SomeView")
})
.padding(.top)
.padding(.leading)
Spacer()
NavigationLink(destination: Text("SomeOtherView"), label: {
Text("SomeOtherView")
})
.padding(.top)
.padding(.trailing)
}
}
}

Show leading navigationBarItems button only if shown as a modal

I have a view that can be shown either as a modal, or simply pushed onto a navigation stack. When it's pushed, it has the back button in the top left, and when it's shown as a modal, I want to add a close button (many of my testers were not easily able to figure out that they could slide down the modal and really expected an explicit close button).
Now, I have multiple problems.
How do I figure out if a View is shown modally or not? Or alternatively, if it's not the first view on a navigation stack? In UIKit there are multiple ways to easily do this. Adding a presentationMode #Environment variable doesn't help, because its isPresented value is also true for pushed screens. I could of course pass in a isModal variable myself but it seems weird that's the only way?
How do I conditionally add a leading navigationBarItem? The problem is that if you give nil, even the default back button is hidden.
Code to copy and paste into Xcode and play around with:
import SwiftUI
struct ContentView: View {
#State private var showModal = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.showModal = true
}
NavigationLink("Push", destination: DetailView(isModal: false))
}
.navigationBarTitle("Home")
}
.sheet(isPresented: $showModal) {
NavigationView {
DetailView(isModal: true)
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) private var presentationMode
let isModal: Bool
var body: some View {
Text("Hello World")
.navigationBarTitle(Text("Detail"), displayMode: .inline)
.navigationBarItems(leading: closeButton, trailing: deleteButton)
}
private var closeButton: some View {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "xmark")
.frame(height: 36)
}
}
private var deleteButton: some View {
Button(action: { print("DELETE") }) {
Image(systemName: "trash")
.frame(height: 36)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If I change closeButton to return an optional AnyView? and then return nil when isModal is false, I don't get a back button at all. I also can't call navigationBarItems twice, once with a leading and once with a trailing button, because the latter call overrides the first call. I'm kinda stuck here.
Okay, I managed it. It's not pretty and I am very much open to different suggestions, but it works 😅
import SwiftUI
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
public func conditionalNavigationBarItems(_ condition: Bool, leading: AnyView, trailing: AnyView) -> some View {
Group {
if condition {
self.navigationBarItems(leading: leading, trailing: trailing)
} else {
self
}
}
}
}
struct ContentView: View {
#State private var showModal = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.showModal = true
}
NavigationLink("Push", destination: DetailView(isModal: false))
}
.navigationBarTitle("Home")
}
.sheet(isPresented: $showModal) {
NavigationView {
DetailView(isModal: true)
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) private var presentationMode
let isModal: Bool
var body: some View {
Text("Hello World")
.navigationBarTitle(Text("Detail"), displayMode: .inline)
.navigationBarItems(trailing: deleteButton)
.conditionalNavigationBarItems(isModal, leading: closeButton, trailing: deleteButton)
}
private var closeButton: AnyView {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "xmark")
.frame(height: 36)
}.eraseToAnyView()
}
private var deleteButton: AnyView {
Button(action: { print("DELETE") }) {
Image(systemName: "trash")
.frame(height: 36)
}.eraseToAnyView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I don't see any trouble, just add Dismiss button to your navigation bar. You only have to rearrange your View hierarchy and there is no need to pass any binding to your DetailView
import SwiftUI
struct DetailView: View {
var body: some View {
Text("Detail View")
}
}
struct ContentView: View {
#State var sheet = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.sheet = true
}
NavigationLink("Push", destination: DetailView())
}.navigationBarTitle("Home")
}
.sheet(isPresented: $sheet) {
NavigationView {
DetailView().navigationBarTitle("Title").navigationBarItems(leading: Button(action: {
self.sheet.toggle()
}, label: {
Text("Dismiss")
}))
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You still can dismiss it with swipe down, you can add some buttons (as part of DetailView declaration) ... etc.
When pushed, you have default back button, if shown modaly, you have dismiss
button indeed.
UPDATE (based od discussion)
.sheet(isPresented: $sheet) {
NavigationView {
GeometryReader { proxy in
DetailView().navigationBarTitle("Title")
.navigationBarItems(leading:
HStack {
Button(action: {
self.sheet.toggle()
}, label: {
Text("Dismiss").padding(.horizontal)
})
Color.clear
Button(action: {
}, label: {
Image(systemName: "trash")
.imageScale(.large)
.padding(.horizontal)
})
}.frame(width: proxy.size.width)
)
}
}
}
finally I suggest you to use
extension View {
#available(watchOS, unavailable)
public func navigationBarItems<L, T>(leading: L?, trailing: T) -> some View where L : View, T : View {
Group {
if leading != nil {
self.navigationBarItems(leading: leading!, trailing: trailing)
} else {
self.navigationBarItems(trailing: trailing)
}
}
}
}
Whenever we provide .navigationBarItems(leading: _anything_), ie anything, the standard back button has gone, so you have to provide your own back button conditionally.
The following approach works (tested with Xcode 11.2 / iOS 13.2)
.navigationBarItems(leading: Group {
if isModal {
closeButton
} else {
// custom back button here calling same dismiss
}
}, trailing: deleteButton)
Update: alternate approach might be as follows (tested in same)
var body: some View {
VStack {
if isModal {
Text("Hello")
.navigationBarItems(leading: closeButton, trailing: deleteButton)
} else {
Text("Hello")
.navigationBarItems(trailing: deleteButton)
}
}
.navigationBarTitle("Test", displayMode: .inline)
}

Resources