SwiftUI: how to make network request when value of binding changes? - ios

I'm trying to update my chart view when I swipe to another page in PagerView. When I swipe currentIndex changes. But I don't understand how to get notified when currentIndex changes.
Here is my code:
struct MainView: View {
let network = Network()
#State private var currentIndex: Int = 0
#State private var sources: [Source] = []
var body: some View {
return ZStack {
...
VStack {
Text("Температура")
.defaultFont(font: .system(size: 30), weight: .regular)
PagerView(pagesCount: self.sources.count, currentIndex: self.$currentIndex) {
ForEach(self.sources, id: \.self) { t in
...
}
}
if !sources.isEmpty {
ChartView(sourceId: $sources[currentIndex].id)
} else {
Spacer()
}
}
}
}
}
PagerView binds to currentIndex so when I swipe a page currentIndex changes. ChartView has a method loadData and I want to call it when I swipe a page to load new chart depends on sources[currentIndex].id. Here is code of ChartView:
struct ChartView: View {
#Binding var sourceId: String {
didSet {
loadData()
}
}
#State private var points: [TemperaturePoint] = []
var body: some View {
GeometryReader { proxy in
ChartView.makePath(by: self.points, with: proxy)
.stroke(Color.white, lineWidth: 2)
}.onAppear(perform: loadData)
}
func loadData() {
network.getPoints(sourceId: sourceId) { response in
switch response {
case .result(let array):
self.points = TemperaturePoint.smooth(points: array.results)
case .error(let error):
print(error)
}
}
}
}
So question is how to make network calling when Binding variable changes? Or maybe I mistake and need to use another way to code this.

When bound state is changed only body of view with #Binding is called, ie. actually only refresh happens, and only of part dependent of bound state, so even .onAppear is not called.
Taking about into account and the fact that entire ChartView depends on new identifier, the solution would be to make force refresh view as whole, so .onAppear called again, and loaded new data.
Here is code. Tested with Xcode 11.4 / iOS 13.4 (with some simplified replicated version).
struct ChartView: View {
#Binding var sourceId: String // << just binding
#State private var points: [TemperaturePoint] = []
var body: some View {
GeometryReader { proxy in
ChartView.makePath(by: self.points, with: proxy)
.stroke(Color.white, lineWidth: 2)
}
.id(sourceId) // << here !!
.onAppear(perform: loadData)
}
func loadData() {
network.getPoints(sourceId: sourceId) { response in
switch response {
case .result(let array):
self.points = TemperaturePoint.smooth(points: array.results)
case .error(let error):
print(error)
}
}
}
}

Related

Toggling from Picker to Image view causes an index out of range error in SwiftUI

I have a view that uses a button to toggle between a Picker and an Image that is a result of the Picker selection. When quickly toggling from the image to the Picker and immediately back, I get a crash with the following error:
Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range
Toggling less quickly doesn't cause this, nor does toggling in the other direction (picker to image and back). Here is the offending code:
import SwiftUI
struct ContentView: View {
#State private var showingPicker = false
#State private var currentNum = 0
#State private var numbers: [Int] = [1, 2, 3, 4, 5]
var body: some View {
VStack(spacing: 15) {
Spacer()
if showingPicker {
Picker("Number", selection: $currentNum) {
ForEach(0..<numbers.count, id: \.self) {
Text("\($0)")
}
}
.pickerStyle(.wheel)
} else {
Image(systemName: "\(currentNum).circle")
}
Spacer()
Button("Toggle") {
showingPicker = !showingPicker
}
}
}
}
The code works otherwise. I'm new to SwiftUI so I'm still wrapping my head around how views are created/destroyed. I tried changing the order of the properties thinking maybe the array was being accessed before it was recreated(if that's even something that happens) but that had no effect. I also tried ForEach(numbers.indices) instead of ForEach(0..<numbers.count), but it has the same result.
**Edit
I figured out a stop-gap for now. I added #State private var buttonEnabled = true and modified the button:
Button("Toggle") {
showingPicker = !showingPicker
buttonEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
buttonEnabled = true
}
}
.disabled(buttonEnabled == false)
To debounce it. I still want to figure out the problem and make a real fix.
**Edit
Based on comments I've modified the code to take array indexing out of the equation and to better reflect the actual project I'm working on. The code still works, but a quick toggle will cause the exact same crash and error. It also seems to only happen when the .wheel style picker is used, other picker styles don't have this behavior.
enum Icons: String, CaseIterable, Identifiable {
case ear = "Ear"
case cube = "Cube"
case eye = "Eye"
case forward = "Forward"
case gear = "Gear"
func image() -> Image {
switch self {
case .ear:
return Image(systemName: "ear")
case .cube:
return Image(systemName: "cube")
case .eye:
return Image(systemName: "eye")
case .forward:
return Image(systemName: "forward")
case .gear:
return Image(systemName: "gear")
}
}
var id: Self {
return self
}
}
struct ContentView: View {
#State private var showingPicker = false
#State private var currentIcon = Icons.allCases[0]
var body: some View {
VStack(spacing: 15) {
Spacer()
if showingPicker {
Picker("Icon", selection: $currentIcon) {
ForEach(Icons.allCases) {
$0.image()
}
}
.pickerStyle(.wheel)
} else {
currentIcon.image()
}
Spacer()
Button("Toggle") {
showingPicker.toggle()
}
}
}
}
** Edited once more to remove .self, still no change
ForEach is not a for loop, you can't use array.count and id:\.self you need to use a real id param or use the Identifiable protocol.
However if you just need numbers it also supports this:
ForEach(0..<5) { i in
As long as you don't try to look up an array using i.

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
}
}
}
}

Why does this SwiftUI List require an extra objectWillChange.send?

Here is a simple list view of "Topic" struct items. The goal is to present an editor view when a row of the list is tapped. In this code, tapping a row is expected to cause the selected topic to be stored as "tappedTopic" in an #State var and sets a Boolean #State var that causes the EditorV to be presented.
When the code as shown is run and a line is tapped, its topic name prints properly in the Print statement in the Button action, but then the app crashes because self.tappedTopic! finds tappedTopic to be nil in the EditTopicV(...) line.
If the line "tlVM.objectWillChange.send()" is uncommented, the code runs fine. Why is this needed?
And a second puzzle: in the case where the code runs fine, with the objectWillChange.send() uncommented, a print statement in the EditTopicV init() shows that it runs twice. Why?
Any help would be greatly appreciated. I am using Xcode 13.2.1 and my deployment target is set to iOS 15.1.
Topic.swift:
struct Topic: Identifiable {
var name: String = "Default"
var iconName: String = "circle"
var id: String { name }
}
TopicListV.swift:
struct TopicListV: View {
#ObservedObject var tlVM: TopicListVM
#State var tappedTopic: Topic? = nil
#State var doEditTappedTopic = false
var body: some View {
VStack(alignment: .leading) {
List {
ForEach(tlVM.topics) { topic in
Button(action: {
tappedTopic = topic
// why is the following line needed?
tlVM.objectWillChange.send()
doEditTappedTopic = true
print("Tapped topic = \(tappedTopic!.name)")
}) {
Label(topic.name, systemImage: topic.iconName)
.padding(10)
}
}
}
Spacer()
}
.sheet(isPresented: $doEditTappedTopic) {
EditTopicV(tlVM: tlVM, originalTopic: self.tappedTopic!)
}
}
}
EditTopicV.swift (Editor View):
struct EditTopicV: View {
#ObservedObject var tlVM: TopicListVM
#Environment(\.presentationMode) var presentationMode
let originalTopic: Topic
#State private var editTopic: Topic
#State private var ic = "circle"
let iconList = ["circle", "leaf", "photo"]
init(tlVM: TopicListVM, originalTopic: Topic) {
print("DBG: EditTopicV: originalTopic = \(originalTopic)")
self.tlVM = tlVM
self.originalTopic = originalTopic
self._editTopic = .init(initialValue: originalTopic)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
Spacer()
Button("Save") {
editTopic.iconName = editTopic.iconName.lowercased()
tlVM.change(topic: originalTopic, to: editTopic)
presentationMode.wrappedValue.dismiss()
}
}
HStack {
Text("Name:")
TextField("name", text: $editTopic.name)
Spacer()
}
Picker("Color Theme", selection: $editTopic.iconName) {
ForEach(iconList, id: \.self) { icon in
Text(icon).tag(icon)
}
}
.pickerStyle(.segmented)
Spacer()
}
.padding()
}
}
TopicListVM.swift (Observable Object View Model):
class TopicListVM: ObservableObject {
#Published var topics = [Topic]()
func append(topic: Topic) {
topics.append(topic)
}
func change(topic: Topic, to newTopic: Topic) {
if let index = topics.firstIndex(where: { $0.name == topic.name }) {
topics[index] = newTopic
}
}
static func ex1() -> TopicListVM {
let tvm = TopicListVM()
tvm.append(topic: Topic(name: "leaves", iconName: "leaf"))
tvm.append(topic: Topic(name: "photos", iconName: "photo"))
tvm.append(topic: Topic(name: "shapes", iconName: "circle"))
return tvm
}
}
Here's what the list looks like:
Using sheet(isPresented:) has the tendency to cause issues like this because SwiftUI calculates the destination view in a sequence that doesn't always seem to make sense. In your case, using objectWillSend on the view model, even though it shouldn't have any effect, seems to delay the calculation of your force-unwrapped variable and avoids the crash.
To solve this, use the sheet(item:) form:
.sheet(item: $tappedTopic) { item in
EditTopicV(tlVM: tlVM, originalTopic: item)
}
Then, your item gets passed in the closure safely and there's no reason for a force unwrap.
You can also capture tappedTopic for a similar result, but you still have to force unwrap it, which is generally something we want to avoid:
.sheet(isPresented: $doEditTappedTopic) { [tappedTopic] in
EditTopicV(tlVM: tlVM, originalTopic: tappedTopic!)
}

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()
}
}
}

SwiftUI View - viewDidLoad()?

Trying to load an image after the view loads, the model object driving the view (see MovieDetail below) has a urlString. Because a SwiftUI View element has no life cycle methods (and there's not a view controller driving things) what is the best way to handle this?
The main issue I'm having is no matter which way I try to solve the problem (Binding an object or using a State variable), my View doesn't have the urlString until after it loads...
// movie object
struct Movie: Decodable, Identifiable {
let id: String
let title: String
let year: String
let type: String
var posterUrl: String
private enum CodingKeys: String, CodingKey {
case id = "imdbID"
case title = "Title"
case year = "Year"
case type = "Type"
case posterUrl = "Poster"
}
}
// root content list view that navigates to the detail view
struct ContentView : View {
var movies: [Movie]
var body: some View {
NavigationView {
List(movies) { movie in
NavigationButton(destination: MovieDetail(movie: movie)) {
MovieRow(movie: movie)
}
}
.navigationBarTitle(Text("Star Wars Movies"))
}
}
}
// detail view that needs to make the asynchronous call
struct MovieDetail : View {
let movie: Movie
#State var imageObject = BoundImageObject()
var body: some View {
HStack(alignment: .top) {
VStack {
Image(uiImage: imageObject.image)
.scaledToFit()
Text(movie.title)
.font(.subheadline)
}
}
}
}
We can achieve this using view modifier.
Create ViewModifier:
struct ViewDidLoadModifier: ViewModifier {
#State private var didLoad = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content.onAppear {
if didLoad == false {
didLoad = true
action?()
}
}
}
}
Create View extension:
extension View {
func onLoad(perform action: (() -> Void)? = nil) -> some View {
modifier(ViewDidLoadModifier(perform: action))
}
}
Use like this:
struct SomeView: View {
var body: some View {
VStack {
Text("HELLO!")
}.onLoad {
print("onLoad")
}
}
}
I hope this is helpful. I found a blogpost that talks about doing stuff onAppear for a navigation view.
Idea would be that you bake your service into a BindableObject and subscribe to those updates in your view.
struct SearchView : View {
#State private var query: String = "Swift"
#EnvironmentObject var repoStore: ReposStore
var body: some View {
NavigationView {
List {
TextField($query, placeholder: Text("type something..."), onCommit: fetch)
ForEach(repoStore.repos) { repo in
RepoRow(repo: repo)
}
}.navigationBarTitle(Text("Search"))
}.onAppear(perform: fetch)
}
private func fetch() {
repoStore.fetch(matching: query)
}
}
import SwiftUI
import Combine
class ReposStore: BindableObject {
var repos: [Repo] = [] {
didSet {
didChange.send(self)
}
}
var didChange = PassthroughSubject<ReposStore, Never>()
let service: GithubService
init(service: GithubService) {
self.service = service
}
func fetch(matching query: String) {
service.search(matching: query) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let repos): self?.repos = repos
case .failure: self?.repos = []
}
}
}
}
}
Credit to: Majid Jabrayilov
Fully updated for Xcode 11.2, Swift 5.0
I think the viewDidLoad() just equal to implement in the body closure.
SwiftUI gives us equivalents to UIKit’s viewDidAppear() and viewDidDisappear() in the form of onAppear() and onDisappear(). You can attach any code to these two events that you want, and SwiftUI will execute them when they occur.
As an example, this creates two views that use onAppear() and onDisappear() to print messages, with a navigation link to move between the two:
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Hello World")
}
}
}.onAppear {
print("ContentView appeared!")
}.onDisappear {
print("ContentView disappeared!")
}
}
}
ref: https://www.hackingwithswift.com/quick-start/swiftui/how-to-respond-to-view-lifecycle-events-onappear-and-ondisappear
I'm using init() instead. I think onApear() is not an alternative to viewDidLoad(). Because onApear is called when your view is being appeared. Since your view can be appear multiple times it conflicts with viewDidLoad which is called once.
Imagine having a TabView. By swiping through pages onApear() is being called multiple times. However viewDidLoad() is called just once.

Resources