SwiftUI: Animate list after ObservedObject updates - ios

I am attempting to animate the insertions of listItems in a List after fetching data from an API. I first show a ProgressView when the app starts calling the API, and then displays the list after API call returns.
Currently, my implementation results in a tableView.reloadData() like animation which causes a visual jerk. I would like to achieve a soft animation by using .animation(.default).
struct ContentView: View {
#ObservedObject private var manager = APIManager()
var body: some View {
NavigationView {
Group {
if manager.isLoading {
ProgressView()
} else {
List(0..<20) { i in
Text("\(i)")
.animation(.default)
}
}
}
.navigationTitle("Items")
}
}
}
class APIManager: ObservableObject {
#Published var isLoading = false
init() {
fetchData()
}
func fetchData() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
self.isLoading = false
}
}
}
}

Try to remove withAnimation {} wrapping -
func fetchData() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
// no animation needed
// withAnimation {
self.isLoading = false
// }
}
}

Related

Async update in MVVM and animation on state change

I trying to understand how Combine and SwiftUI works in combination with MVVM and clean architecture, but I encountered a problem with using withAnimation once my view model has an async method that updated published value. I was able to solve it, but I'm pretty sure it's not the correct way and I'm missing something fundamental. Here it is how it looks my solution, starting with my data manager:
protocol NameManaging {
var publisher: AnyPublisher<[Name], Never> { get }
func fetchNames() async
}
class MockNameManager: NameManaging {
var publisher: AnyPublisher<[Name], Never> {
names.eraseToAnyPublisher()
}
func fetchNames() async {
var values = await heavyAsyncTask()
names.value.append(contentsOf: values)
}
private func heavyAsyncTask() async -> [Name] {
// do some heavy async task
}
private var names = CurrentValueSubject<[Name], Never>([])
}
Then view models:
class NameListViewModel: ObservableObject {
#Published var names = [Name]()
private var anyCancellable: AnyCancellable?
private var nameManager: NameManaging
init(nameManager: NameManaging = MockNameManager()) {
self.nameManager = nameManager
self.anyCancellable = nameManager.publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] values in
withAnimation {
self?.names = values
}
})
}
func fetchNames() async {
await nameManager.fetchNames()
}
}
Lastly my view:
struct NameList: View {
#StateObject private var nameListViewModel = NameListViewModel()
var body: some View {
VStack {
VStack {
HStack {
Button(action: updateNames) {
Text("Fetch some more names")
}
}
}
.padding()
List {
ForEach(nameListViewModel.names) {
NameRow(name: $0)
}
}
}
.navigationTitle("Names list")
.onAppear(perform: updateNames)
}
func updateNames() {
Task {
await nameListViewModel.fetchNames()
}
}
}
What I did is use withAnimation inside my view model in .sink() method of data manager publisher. This works as expected, but it indroduce view function inside view model. How can I do it in a way that inside updateNames in my view I'll use withAnimation? Or maybe I should do it in completely different way?
You are mixing up technologies. The point of async/await is to remove the need for a state object (i.e. a reference type) and Combine to do async work. You can simply use the .task modifier to call any async func and set the result on an #State var. If the async func throws then you might catch the exception and set a message on another #State var. The great thing about .task is it's called when the UIView (that SwiftUI creates for you) appears and cancelled when it disappears (also if the optional id param changes). So no need for an object, which often is the cause of consistency/memory problems which Swift and SwiftUI's use of value types is designed to eliminate.
struct NameList: View {
#State var var names: [Name] = []
#State var fetchCount = 0
var body: some View {
VStack {
VStack {
HStack {
Button("Fetch some more names") {
fetchCount += 1
}
}
}
.padding()
List {
ForEach(names) { name in
NameRow(name: name)
}
}
}
.navigationTitle("Names list")
.task(id: fetchCount) {
let names = await Name.fetchNames()
withAnimation {
self.names = names
}
}
}
}

UICloudSharingController Does not Display/Work with CloudKit App

I have been struggling to get an app to work with CloudKit and record sharing. I have
created several apps that sync Core Data records among devices for one user. I have not
been able to get the UICloudSharingController to work for sharing records. I can display
the Sharing View Controller, but tapping on Mail or Message displays a keyboard but no
address field and no way to dismiss the view. I have been so frustrated by it that I
decided to try the Apple "Sharing" sample app to start from the basics. However, the
sample app does not work for me either.
Here's the link to the sample app:
https://github.com/apple/cloudkit-sample-sharing/tree/swift-concurrency
The code below is pretty much straight from the sample app.
This is the ContentView file:
import SwiftUI
import CloudKit
struct ContentView: View {
#EnvironmentObject private var vm: ViewModel
#State private var isAddingContact = false
#State private var isSharing = false
#State private var isProcessingShare = false
#State private var showShareView = false
#State private var showIntermediateView = false
#State private var activeShare: CKShare?
#State private var activeContainer: CKContainer?
var body: some View {
NavigationView {
contentView
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { Task.init { try await vm.refresh() } } label: { Image(systemName: "arrow.clockwise") }
}
ToolbarItem(placement: .navigationBarLeading) {
progressView
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isAddingContact = true }) { Image(systemName: "plus") }
}
}
}
.onAppear {
Task.init {
try await vm.initialize()
try await vm.refresh()
}
}
.sheet(isPresented: $isAddingContact, content: {
AddContactView(onAdd: addContact, onCancel: { isAddingContact = false })
})
}
/// This progress view will display when either the ViewModel is loading, or a share is processing.
var progressView: some View {
let showProgress: Bool = {
if case .loading = vm.state {
return true
} else if isProcessingShare {
return true
}
return false
}()
return Group {
if showProgress {
ProgressView()
}
}
}
/// Dynamic view built from ViewModel state.
private var contentView: some View {
Group {
switch vm.state {
case let .loaded(privateContacts, sharedContacts):
List {
Section(header: Text("Private")) {
ForEach(privateContacts) { contactRowView(for: $0) }
}
Section(header: Text("Shared")) {
ForEach(sharedContacts) { contactRowView(for: $0, shareable: false) }
}
}.listStyle(GroupedListStyle())
case .error(let error):
VStack {
Text("An error occurred: \(error.localizedDescription)").padding()
Spacer()
}
case .loading:
VStack { EmptyView() }
}
}
}
/// Builds a `CloudSharingView` with state after processing a share.
private func shareView() -> CloudSharingView? {
guard let share = activeShare, let container = activeContainer else {
return nil
}
return CloudSharingView(container: container, share: share)
}
/// Builds a Contact row view for display contact information in a List.
private func contactRowView(for contact: Contact, shareable: Bool = true) -> some View {
HStack {
VStack(alignment: .leading) {
Text(contact.name)
Text(contact.phoneNumber)
.textContentType(.telephoneNumber)
.font(.footnote)
}
if shareable {
Spacer()
Button(action: { Task.init { try? await shareContact(contact) }
}, label: { Image(systemName: "square.and.arrow.up") }).buttonStyle(BorderlessButtonStyle())
.sheet(isPresented: $isSharing, content: { shareView() })
}//if sharable
}//h
}//contact row view
// MARK: - Actions
private func addContact(name: String, phoneNumber: String) async throws {
try await vm.addContact(name: name, phoneNumber: phoneNumber)
try await vm.refresh()
isAddingContact = false
}
private func shareContact(_ contact: Contact) async throws {
isProcessingShare = true
do {
let (share, container) = try await vm.createShare(contact: contact)
isProcessingShare = false
activeShare = share
activeContainer = container
isSharing = true
} catch {
debugPrint("Error sharing contact record: \(error)")
}
}
}
And the UIViewControllerRepresentable file for the sharing view:
import Foundation
import SwiftUI
import UIKit
import CloudKit
/// This struct wraps a `UIImagePickerController` for use in SwiftUI.
struct CloudSharingView: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
let container: CKContainer
let share: CKShare
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeUIViewController(context: Context) -> some UIViewController {
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
return sharingController
}
func makeCoordinator() -> CloudSharingView.Coordinator {
Coordinator()
}
final class Coordinator: NSObject, UICloudSharingControllerDelegate {
func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) {
debugPrint("Error saving share: \(error)")
}
func itemTitle(for csc: UICloudSharingController) -> String? {
"Sharing Example"
}
}
}
This is the presented screen when tapping to share a record. This all looks as expected:
This is the screen after tapping Messages (same result for Mail). I don't see any way to influence the second screen presentation - it seems that the view controller representable is not working with this version of Xcode:
Any guidance would be appreciated. Xcode 13.1, iOS 15 and I am on macOS Monterrey.
I had the same issue and fixed it by changing makeUIViewController() in CloudSharingView.swift:
func makeUIViewController(context: Context) -> some UIViewController {
share[CKShare.SystemFieldKey.title] = "Boom"
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
-->>> sharingController.modalPresentationStyle = .none
return sharingController
}
It seems like some value work, some don't. Not sure why.

SwiftUI: stuck on infinite page view implementation

I'm trying to create a PageView in pure SwiftUI. There's my test code below. And everything works as expected but the DragGesture. It just doesn't call 'onEnded' function. Never. How can I fix it?
struct PageView<V: View>: Identifiable {
let id = UUID()
var content: V
}
struct InfinitePageView: View {
#State private var pages: [PageView] = [
PageView(content: Text("Page")),
PageView(content: Text("Page")),
PageView(content: Text("Page"))
]
#State private var selectedIndex: Int = 1
#State private var isDragging: Bool = false
private var drag: some Gesture {
DragGesture()
.onChanged { _ in
self.isDragging = true
}
.onEnded { _ in
self.isDragging = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
resolvePages()
}
}
}
var body: some View {
NavigationView {
TabView(selection: $selectedIndex) {
ForEach(pages) { page in
page.content
.tag(pages.firstIndex(where: { $0.id == page.id })!)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.gesture(drag)
.onChange(of: selectedIndex, perform: { value in
guard !isDragging else { return }
DispatchQueue.main.async {
resolvePages()
}
})
}
}
private func resolvePages() {
if selectedIndex > 1 {
addNextPage()
}
if selectedIndex < 1 {
addPreviousPage()
}
}
private func addNextPage() {
pages.append(PageView(content: Text("Page")))
pages.removeFirst()
selectedIndex = 1
}
private func addPreviousPage() {
pages.insert(PageView(content: Text("Page")), at: 0)
pages.removeLast()
selectedIndex = 1
}
}
This is a known issue with SwiftUI
the DragGesture that you setup likely gets overridden by the DragGesture within the TabView.
Can detect onEnded with the setup in this post Detect DragGesture cancelation in SwiftUI but that deactivates the TabView/Page gestures.
Your onChange code for selectedIndex runs once the new page is selected and would act should act the same as onEnded.

How can I get UNNotificationRequest array using for loop to populate SwiftUI List view?

I want to simply get the list of local notifications that are scheduled and populate a SwiftUI List using forEach. I believe it should work like I have done below, but the array is always empty as it seems to be used before the for loop is finished. I tried the getNotifications() function with a completion handler, and also as a return function, but both ways still didn't work. How can I wait until the for loop is done to populate my list? Or if there is another way to do this please let me know, thank you.
var notificationArray = [UNNotificationRequest]()
func getNotifications() {
print("getNotifications")
center.getPendingNotificationRequests(completionHandler: { requests in
for request in requests {
print(request.content.title)
notificationArray.append(request)
}
})
}
struct ListView: View {
var body: some View {
NavigationView {
List {
ForEach(notificationArray, id: \.content) { notification in
HStack {
VStack(alignment: .leading, spacing: 10) {
let notif = notification.content
Text(notif.title)
Text(notif.subtitle)
.opacity(0.5)
}
}
}
}
.onAppear() {
getNotifications()
}
}
}
Update:
Here is how I am adding a new notification and calling getNotifications again. I want the list to dynamically update as the new array is made. Printing to console shows that the getNotifications is working correctly and the new array contains the added notiication.
Section {
Button(action: {
print("Adding Notification: ", title, bodyText, timeIntValue[previewIndex])
addNotification(title: title, bodyText: bodyText, timeInt: timeIntValue[previewIndex])
showDetail = false
self.vm.getNotifications()
}) {
Text("Save Notification")
}
}.disabled(title.isEmpty || bodyText.isEmpty)
Your global notificationArray is not observed by view. It should be dynamic property... possible solution is to wrap it into ObservableObject view model.
Here is a demo of solution:
class ViewModel: ObservableObject {
#Published var notificationArray = [UNNotificationRequest]()
func getNotifications() {
print("getNotifications")
center.getPendingNotificationRequests(completionHandler: { requests in
var newArray = [UNNotificationRequest]()
for request in requests {
print(request.content.title)
newArray.append(request)
}
DispatchQueue.main.async {
self.notificationArray = newArray
}
})
}
}
struct ListView: View {
#ObservedObject var vm = ViewModel()
//#StateObject var vm = ViewModel() // << for SwiftUI 2.0
var body: some View {
NavigationView {
List {
ForEach(vm.notificationArray, id: \.content) { notification in
HStack {
VStack(alignment: .leading, spacing: 10) {
let notif = notification.content
Text(notif.title)
Text(notif.subtitle)
.opacity(0.5)
}
}
}
}
.onAppear() {
self.vm.getNotifications()
}
}
}

Pausing a Notification publisher in SwiftUI

When I call a backend service (login, value check…) I use a Notification publisher on the concerned Views to manage the update asynchronously.
I want to unsubscribe to the notifications when the view disappear, or « pause » the publisher.
I went first with the simple « assign » option from the WWDC19 Combine and related SwiftUI talks, then I looked at this great post and the onReceive modifier. However the view keeps updating with the published value even when the view is not visible.
My questions are:
Can I « pause » this publisher when the view is not visible ?
Should I really be concerned by this, does it affect resources (the backend update could trigger a big refresh on list and images display) or should I just let SwiftUI manage under the hood ?
The sample code:
Option 1: onReceive
struct ContentView: View {
#State var info:String = "???"
let provider = DataProvider() // Local for demo purpose, use another pattern
let publisher = NotificationCenter.default.publisher(for: DataProvider.updated)
.map { notification in
return notification.userInfo?["data"] as! String
}
.receive(on: RunLoop.main)
var body: some View {
TabView {
VStack {
Text("Info: \(info)")
Button(action: {
self.provider.startNotifications()
}) {
Text("Start notifications")
}
}
.onReceive(publisher) { (payload) in
self.info = payload
}
.tabItem {
Image(systemName: "1.circle")
Text("Notifications")
}
VStack {
Text("AnotherView")
}
.tabItem {
Image(systemName: "2.circle")
Text("Nothing")
}
}
}
}
Option 2: onAppear / onDisappear
struct ContentView: View {
#State var info:String = "???"
let provider = DataProvider() // Local for demo purpose, use another pattern
#State var cancel: AnyCancellable? = nil
var body: some View {
TabView {
VStack {
Text("Info: \(info)")
Button(action: {
self.provider.startNotifications()
}) {
Text("Start notifications")
}
}
.onAppear(perform: subscribeToNotifications)
.onDisappear(perform: unsubscribeToNotifications)
.tabItem {
Image(systemName: "1.circle")
Text("Notifications")
}
VStack {
Text("AnotherView")
}
.tabItem {
Image(systemName: "2.circle")
Text("Nothing")
}
}
}
private func subscribeToNotifications() {
// publisher to emit events when the default NotificationCenter broadcasts the notification
let publisher = NotificationCenter.default.publisher(for: DataProvider.updated)
.map { notification in
return notification.userInfo?["data"] as! String
}
.receive(on: RunLoop.main)
// keep reference to Cancellable, and assign String value to property
cancel = publisher.assign(to: \.info, on: self)
}
private func unsubscribeToNotifications() {
guard cancel != nil else {
return
}
cancel?.cancel()
}
}
For this test, I use a dummy service:
class DataProvider {
static let updated = Notification.Name("Updated")
var payload = "nothing"
private var running = true
func fetchSomeData() {
payload = Date().description
print("DEBUG new payload : \(payload)")
let dictionary = ["data":payload] // key 'data' provides payload
NotificationCenter.default.post(name: DataProvider.updated, object: self, userInfo: dictionary)
}
func startNotifications() {
running = true
runNotification()
}
private func runNotification() {
if self.running {
self.fetchSomeData()
let soon = DispatchTime.now().advanced(by: DispatchTimeInterval.seconds(3))
DispatchQueue.main.asyncAfter(deadline: soon) {
self.runNotification()
}
} else {
print("DEBUG runNotification will no longer run")
}
}
func stopNotifications() {
running = false
}
}
It seems that there are two publishers name let publisher in your program. Please remove one set of them. Also self.info = payload and publisher.assign(to: \.info, on: self)} are duplicating.
}
.onAppear(perform: subscribeToNotifications)
.onDisappear(perform: unsubscribeToNotifications)
.onReceive(publisher) { (payload) in
// self.info = payload
print(payload)
}
.tabItem {
In the following:
#State var cancel: AnyCancellable? = nil
private func subscribeToNotifications() {
// publisher to emit events when the default NotificationCenter broadcasts the notification
// let publisher = NotificationCenter.default.publisher(for: DataProvider.updated)
// .map { notification in
// return notification.userInfo?["data"] as! String
// }
// .receive(on: RunLoop.main)
// keep reference to Cancellable, and assign String value to property
if cancel == nil{
cancel = publisher.assign(to: \.info, on: self)}
}
private func unsubscribeToNotifications() {
guard cancel != nil else {
return
}
cancel?.cancel()
}
Now you can see, cancel?.cancel() does work and the info label no longer update after you come back from tab2. ~~~Publisher pause Here because subscription has been cancelled.~~~
Publisher is not paused as there is another subscriber in the view , so the print(payload) still works.

Resources