Animate Remove SwiftUI View Glitches - ios

I would like to slide in a view over the bottom, to display a small note, and then slide off after a few seconds..
slide in is working fine, but when it slides out, it glitches:
if let err = error {
ErrorView(err)
.animation(.spring())
.transition(.move(edge: .bottom))
.onAppear(perform: errorAppeared)
}
When there is an error, it transitions just fine, but when there isn't, instead of sliding down or inversing, it just pops off the view
I am calling this function to clear the view:
private func clearError() {
withAnimation {
self.error = nil
}
}
none of the existing questions helped, I tried to do things in the 'onReceive' function and about every other answer I could find, most say it should just work, or to use animation, or transition, I believe I've tried most combinations and at this point am quite stuck

I assume the animation is just in wrong place - put it for container holding condition and link it to dependent state variable, like below
VStack { // << can be as-is, only for condition
if let err = error {
ErrorView(err)
.transition(.move(edge: .bottom))
.onAppear(perform: errorAppeared)
}
}
.animation(.spring(), value: error) // << here !!
and then just activate in regular way
private func clearError() {
self.error = nil
}

Related

How to disable transition animation when reduce motion is turned off

There's a view in my app called ContinueView that slides out of sight when a button is clicked and it works like so:
struct ContentView: View {
#Environment(\.accessibilityReduceMotion) var isReducedMotion
#State private var hasRequestedNotifications = UserDefaults.standard.bool(forKey: "hasRequestedNotifications")
#State private var isShowingContinueView = !UserDefaults.standard.bool(forKey: "isLoggedIn")
var body: some View {
.........
if (isShowingContinueView) {
ContinueView(isShowingContinueView: $isShowingContinueView).transition(.asymmetric(insertion: AnyTransition.move(edge: .trailing).combined(with: .opacity), removal: AnyTransition.move(edge: .leading).combined(with: .opacity)))
} else if (!hasRequestedNotifications) {
AllowNotificationsView(hasRequestedNotifications: $hasRequestedNotifications).transition(.asymmetric(insertion: AnyTransition.move(edge: .trailing).combined(with: .opacity), removal: AnyTransition.move(edge: .leading).combined(with: .opacity)))
}
........
}
}
ContinueView:
....
Button("Continue") {
withAnimation {
self.isShowingContinueView.wrappedValue = false
}
}
.....
This works perfectly, it shows and hides the view with a smooth sliding transition. But for users with reduced motion, I wanted to support it too so that instead of sliding, it just shows and hides with no animation at all.
I've got an enviroment variable at the top of my contentView called: isReducedMotion, with this I want to toggle motion on and off dependent on what the variable is set to.
I've tried putting it like this:
ContinueView(isShowingContinueView: $isShowingContinueView).transition(isReducedMotion ? nil : .asymmetric(insertion: AnyTransition.move(edge: .trailing).combined(with: .opacity), removal: AnyTransition.move(edge: .leading).combined(with: .opacity)))
But it shows this error: 'nil' cannot be used in context expecting type 'AnyTransition'
So i'm a bit stuck with it and not sure how to get it to work. Does anyone know how to do this? Any help would be really appreciated.
P.S: The code i've put here is modified so its a bit simpler, but yes, the state variables work fine, and the isShowingContinueView variable does get set to true/false elsewhere in the code :)
Also if you have any questions feel free to hit me up since I know my question might be a bit confusing :D
You can try using .identity instead of nil for the transition, or you can pass nil to withAnimation to specify no animation:
Button("Continue") {
withAnimation(isReducedMotion ? nil : .default) {
self.isShowingContinueView.wrappedValue = false
}
}

SwiftUI: backwards infinite scroll

I am showing the conversation in the view, initially only the end of the conversation is loaded. To simplify it's something like this:
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(items) { item in
itemView(item)
.onAppear { prependItems(item) }
}
.onAppear {
if let id = items.last?.id {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
}
func prependItems(item: Item) {
// return if already loading
// or if the item that fired onAppear
// is not close to the beginning of the list
// ...
let moreItems = loadPreviousItems(items)
items.insert(contentsOf: moreItems, at: 0)
}
The problem is that when the items are prepended to the list, the list view position remains the same relative to the new start of the list, and trying to programmatically scroll back to the item that fired loading previous items does not work if scrollbar is moving at the time...
A possible solution I can think of would be to flip the whole list view upside down, reverse the list (so that the new items are appended rather than prepended), then flip each item upside down, but firstly it is some terrible hack, and, more importantly, the scrollbar would be on the left...
Is there a better solution for backwards infinite scroll in SwiftUI?
EDIT: it is possible to avoid left scrollbar by using scaleEffect(CGSize(width: 1, height: -1)) instead of rotationEffect(.degrees(180)), but in either case item contextMenu is broken one way or another, so it is not a viable option, unfortunately, as otherwise scaleEffect works reasonably well...
EDIT2: The answer that helps fixing broken context menu, e.g. with a custom context menu in UIKit or in some other way, can also be acceptable, and I posted it to freelancer in case somebody is interested to help with that: https://www.freelancer.com/projects/swift/Custom-UIKit-context-menu-SwiftUI/details
Have you tried this?
self.data = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01)
{
self.data = self.idx == 0 ? ContentView.data1 : ContentView.data2
}
Basically what this does is first empty the array and then set it again to something, but with a little delay. That way the ScrollView empties out, resets the scroll position (as it's empty) and repopulates with the new data and scrolled to the top.
So far the only solution I could find was to flip the view and each item:
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(items.reversed()) { item in
itemView(item)
.onAppear { prependItems(item) }
.scaleEffect(x: 1, y -1)
}
.onAppear {
if let id = items.last?.id {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
.scaleEffect(x: 1, y -1)
}
func prependItems(item: Item) {
// return if already loading
// or if the item that fired onAppear
// is not close to the beginning of the list
// ...
let moreItems = loadPreviousItems(items)
items.insert(contentsOf: moreItems, at: 0)
}
I ended up maintaining reversed model, instead of reversing on the fly as in the above sample, as on long lists the updates become very slow.
This approach breaks swiftUI context menu, as I wrote in the question, so UIKit context menu should be used. The only solution that worked for me, with dynamically sized items and allowing interactions with the item of the list was this one.
What it is doing, effectively, is putting a transparent overlay view with an attached context menu on top of SwiftUI list item, and then putting a copy of the original SwiftUI item on top - not doing this last step makes an item that does not allow tap interactions, so if it were acceptable - it would be better. The linked answer allows to briefly see the edges of the original SwiftUI view during the interaction; it can be avoided by making the original view hidden.
The full code we have is here.
The downside of this approach is that copying the original item prevents its partial updates in the copy, so for every update its view ID must change, and it is more visible to the user when the full update happens... So I believe making a fully custom reverse lazy scroll would be a better (but more complex) solution.
We would like to sponsor the development of reverse lazy scroll as an open-source component/library - we would use it in SimpleX Chat and it would be available for any other messaging applications. Please approach me if you are able and interested to do it.
I found a link related to your question: https://dev.to/gualtierofr/infinite-scrolling-in-swiftui-1p3l
it worked for me so you can implement parts of that in ur code
struct StargazersViewInfiniteScroll: View {
#ObservedObject var viewModel:StargazersViewModel
var body: some View {
List(viewModel.stargazers) { stargazer in
StargazerView(stargazer: stargazer)
.onAppear {
self.elementOnAppear(stargazer)
}
}
}
private func elementOnAppear(_ stargazer:User) {
if self.viewModel.isLastStargazer(stargazer) {
self.viewModel.getNextStargazers { success in
print("next data received")
}
}
}
you can take what you need from here

SwiftUI searchable on NavigationView always shown, hides only on scroll

I'm writing a fairly simple SwiftUI app about movies and I have this issue where the new .searchable modifier on NavigationView is always being shown, whereas it should be hidden, unless you pull down on the List.
It hides it correctly if I scroll a bit up, and if I scroll down it also hides it correctly, but other than that, it's always being shown. See gif for clarification. (basically it should behave the same as in Messages app)
https://imgur.com/R2rsqzh
My code for using the searchable is fairly simple:
var body: some View {
NavigationView {
List {
ForEach(/*** movie stuff ***/) { movie in
///// row here
}
}
.listStyle(GroupedListStyle())
.onAppear {
// load movies
}
.navigationTitle("Discover")
.searchable(text: $moviesRepository.searchText, placement: .toolbar, prompt: "Search...")
}
}
}
So, after adding a progress view above the list view, it suddenly started working the way I want it to. The code now is minimally changed and looks like this.
var body: some View {
NavigationView {
VStack {
if /** we're doing search, I'm checking search text **/ {
ProgressView()
.padding()
}
if !movies.isEmpty {
List {
ForEach(/** movies list **/) { movie in
// movie row here
}
}
.listStyle(GroupedListStyle())
.navigationTitle("Discover")
.searchable(text: $moviesRepository.searchText, placement: .toolbar,
prompt: Text("Search...")
.foregroundColor(.secondary)
)
} else {
ProgressView()
.navigationTitle("Discover")
}
}
}
.onAppear {
// load movies
}
}
And basically after adding the progress views, it started working the way I described it in my OP and the way it worked for ChrisR

How do I stop a view from refreshing each time I return from a detail view in SwiftUI?

I have a view which displays a list of posts. I have implemented infinite scrolling, and it is functioning properly. however, there is one small problem I am running into, and attempts to solve it have me going round in circles.
Main view
struct PostsHomeView: View {
#EnvironmentObject var viewModel: ViewModel
#State var dataInitiallyFetched = false
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 0) {
if self.viewModel.posts.count > 0 {
PostsListView(posts: self.viewModel.posts,
isLoading: self.viewModel.canFetchMorePosts,
onScrolledAtBottom: self.viewModel.fetchMorePosts
)
} else {
VStack {
Text("You have no posts!")
}
}
}
.onAppear() {
if !self.dataInitiallyFetched {
self.viewModel.fetchMostRecentPosts()
self.dataInitiallyFetched = true
}
}
.navigationBarTitle("Posts", displayMode: .inline)
}
}
}
List view
struct PostsListView: View {
#EnvironmentObject var viewModel: ViewModel
let posts: [Post]
let isLoading: Bool
let onScrolledAtBottom: () -> Void
var body: some View {
List {
postsList
if isLoading {
loadingIndicator
}
}
}
private var postsList: some View {
ForEach(posts, id: \.self) { post in
PostsCellView(post: post)
.onAppear {
if self.posts.last == post {
self.onScrolledAtBottom()
}
}
}
.id(UUID())
}
}
Problem
Upon tapping one of the posts in the list, I am taken to a detail view. When I tap the nav bar's back button in order go back to the posts list, the whole view is reloaded and my post fetch methods are fired again.
In order to stop the fetch method that fetches most recent posts from firing, I have added a flag that I set to true after the initial load. This stops the fetch method that grabs the initial set of posts from firing when I go back and forth between the details view and posts home screen.
I have tried various things to stop the fetchMorePosts function from firing, but I keep going in circles. I added a guard statement to the top of the fetchMorePosts function in my view model. It checks to see if string is equal to "homeview", if not, then the fetch is not done. I set this string to "detailview" whenever the detail view is visited, then I reset it back to "homeview" in the guard statement.
guard self.lastView == "homeview" else {
self.lastView = "homeview"
return
}
This works to an extent, but I keep finding scenarios where it doesn't work as expected. There must be a straight-forward way to tell SwiftUI not to reload a view. The problem is the method sits in the onAppear closure which is vital for the infinite scrolling to work. I'm not using iOS 14 yet, so I can't use #StateObject.
Is there a way to tell SwiftUI not to fire onAppear everytime I return from a detail view?
Thanks in advance
The culprit was .id(UUID()). I removed it from my list and everything worked again.
Thanks Asperi. Your help is much appreciated.

SwiftUI View Code is seemingly getting called twice. What is the issue here?

I have a view that is being called using .sheet in SwiftUI. However, when this sheet is presented, I'm getting the debug "Tapped" print in console followed by this error:
Warning: Attempt to present <_TtGC7SwiftUIP13$7fff2c9bdf5c22SheetHostingControllerVS_7AnyView_: 0x7f8f1d7400f0> on <_TtGC7SwiftUI19UIHostingControllerVVS_22_VariadicView_Children7Element_: 0x7f8f17e0dae0> which is already presenting (null)
I'm not exactly sure what is causing this error, but from what I understand it's due to the view getting called twice. I'm not sure how the view is being called twice, or even if it is, which is why I'm asking here. Below is the main view that actually houses the NavigationView in which my List is being housed view
struct AllTablesView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(fetchRequest: Table.getAllTables()) var tables: FetchedResults<Table>
#State private var tableTapped: Table = Table()
#State private var isModal = false
var body: some View {
NavigationView {
List {
ForEach(self.tables) { table in
tableCellView(tableNumber: table.number, clean: table.clean, inUse: table.inUse)
.onTapGesture {
self.tableTapped = table
self.isModal = true
print("tapped")
}
}
.onDelete { indexSet in
let deletedTable = self.tables[indexSet.first!]
self.managedObjectContext.delete(deletedTable)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
.sheet(isPresented: $isModal){
TableStatusView(table: self.tableTapped)
.environment(\.managedObjectContext, self.managedObjectContext)
}
}
.navigationBarTitle("All Tables")
.navigationBarItems(leading: EditButton(), trailing:
HStack {
DeleteAllTablesButton()
AddTableButton()
})
}
}
}
And, if for whatever reason this is the issue, here is the code for my view titled "TableStatusView"
struct TableStatusView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(fetchRequest: Table.getAllTables()) var tables: FetchedResults<Table>
#State var table: Table
var body: some View {
VStack {
Form {
Section {
Text("Table \(table.number)")
}.padding(.all, 10.0)
Section {
Toggle(isOn: $table.inUse) {
Text("In Use")
}.padding(.all, 10.0)
Toggle (isOn: $table.clean) {
Text("Clean")
}.padding(.all, 10.0)
}
}
}
}
}
Is there a reason I'm getting this error, or why my view is being called twice? Is it actually that it's being called twice or am I doing something wrong here? I know certain parts of my code could probably be shrunk down or written better but this is just my first dive into SwiftUI, I haven't had much experience with it outside of basic stuff. Thanks!
I never did find a solution to the question, but I did solve the NavigationLink issue and swapped back to using that. I was using ".onTapGesture" to get a tap gesture and then generate a NavigationLink, thinking it was an action. NavigationLink, I've learned, is actually more or less a container to house content, so I replaced the .onTapGesture and TableCellView() function call with the following:
NavigationLink(destination: TableStatusView(table: table)) {
tableCellView(tableNumber: table.number, clean: table.clean, inUse: table.inUse)
}
And fixed the issue for me. I'm still seeing some errors but from some google-fu found out that they are current bugs of SwiftUI. SwiftUI 2.0 may fix some of these issues

Resources