SwiftUI: Publishing changes from async model - swift

I'm attempting to figure out how to display a message to my users when some asynchronous code takes some time to run. So far I've used a sample I found online to create a popup banner and tied the message together using an ObservedObject of the async method on my view and then Publish the values from my async method.
My sample code project is on a public GitHub repository here and I'll post the code at the bottom.
Right now I have an issue when setting the variables from the async method: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. Solutions online seem to fix this issue by updating the value on the #mainActor thread but I want these methods to run asynchronously AND update the user on what's happening. What's the best way to update my variables from this location?
CODE
in the main app:
var body: some Scene {
WindowGroup {
ContentView(asyncmethod: myAsyncViewModel())
}
}
ContentView:
import SwiftUI
struct ContentView: View {
#State private var isLoaderPresented = false
#State private var isTopMessagePresented = false
#ObservedObject var asyncmethod: myAsyncViewModel
var body: some View {
VStack {
Spacer()
Button( action: {
Task {
isTopMessagePresented = true
let response = await asyncmethod.thisMethodTakesTime()
// Want to return a string or object so I know what happens.
print("Response Loader: \(response ?? "no response")")
isTopMessagePresented = false
}
},
label: { Text("Run Top Banner Code") }
)
Spacer()
}
.foregroundColor(.black)
.popup(isPresented: isTopMessagePresented, alignment: .top, direction: .top, content: {
Snackbar(showForm: $isTopMessagePresented, asyncmethod: asyncmethod)
})
}
}
struct Snackbar: View {
#Binding var showForm: Bool
#ObservedObject var asyncmethod: myAsyncViewModel
var body: some View {
VStack {
HStack() {
Image(systemName: asyncmethod.imageName)
.resizable()
.aspectRatio(contentMode: ContentMode.fill)
.frame(width: 40, height: 40)
Spacer()
VStack(alignment: .center, spacing: 4) {
Text(asyncmethod.title)
.foregroundColor(.black)
.font(.headline)
Text(asyncmethod.subTitle)
.font(.body)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
}
}
.frame(minWidth: 200)
}
.padding(15)
.frame(maxWidth: .infinity, idealHeight: 100)
.background(Color.black.opacity(0.1))
}
}
My async sample method:
import Foundation
class myAsyncViewModel: ObservableObject {
#Published var imageName: String = "questionmark"
#Published var title: String = "title"
#Published var subTitle: String = "subtitle"
func thisMethodTakesTime() async -> String? {
print("In method: \(imageName), \(title), \(subTitle)")
title = "MY METHOD"
subTitle = "Starting out!"
print("In method. Starting \(title)")
subTitle = "This is the message"
print("Sleeping")
try? await Task.sleep(nanoseconds: 1_000_000_000)
subTitle = "Between"
try? await Task.sleep(nanoseconds: 1_000_000_000)
print("After sleep. Ending")
subTitle = "About to return. Success!"
print("In method: \(imageName), \(title), \(subTitle)")
return "RETURN RESULT"
}
}
And the supporting file for the popup:
import SwiftUI
struct Popup<T: View>: ViewModifier {
let popup: T
let isPresented: Bool
let alignment: Alignment
let direction: Direction
// 1.
init(isPresented: Bool, alignment: Alignment, direction: Direction, #ViewBuilder content: () -> T) {
self.isPresented = isPresented
self.alignment = alignment
self.direction = direction
popup = content()
}
// 2.
func body(content: Content) -> some View {
content
.overlay(popupContent())
}
// 3.
#ViewBuilder private func popupContent() -> some View {
GeometryReader { geometry in
if isPresented {
withAnimation {
popup
.transition(.offset(x: 0, y: direction.offset(popupFrame: geometry.frame(in: .global))))
.frame(width: geometry.size.width, height: geometry.size.height, alignment: alignment)
}
}
}
}
}
extension Popup {
enum Direction {
case top, bottom
func offset(popupFrame: CGRect) -> CGFloat {
switch self {
case .top:
let aboveScreenEdge = -popupFrame.maxY
return aboveScreenEdge
case .bottom:
let belowScreenEdge = UIScreen.main.bounds.height - popupFrame.minY
return belowScreenEdge
}
}
}
}
private extension GeometryProxy {
var belowScreenEdge: CGFloat {
UIScreen.main.bounds.height - frame(in: .global).minY
}
}
extension View {
func popup<T: View>(
isPresented: Bool,
alignment: Alignment = .center,
direction: Popup<T>.Direction = .bottom,
#ViewBuilder content: () -> T
) -> some View {
return modifier(Popup(isPresented: isPresented, alignment: alignment, direction: direction, content: content))
}
}
Again all this can be found in my GitHub page here.

You can annotate the observable class or just the function with ‘#MainActor’ or use DispatchQueue.main.async when you assign to the published variables.

Related

SwiftUI View method called but output missing

I have the View AlphabetLetterDetail:
import SwiftUI
struct AlphabetLetterDetail: View {
var alphabetLetter: AlphabetLetter
var letterAnimView : LetterAnimationView
var body: some View {
VStack {
Button(action:
animateLetter
) {
Image(uiImage: UIImage(named: "alpha_be_1")!)
.resizable()
.scaledToFit()
.frame(width: 60.0, height: 120.0)
}
letterAnimView
}.navigationBarTitle(Text(verbatim: alphabetLetter.name), displayMode: .inline)
}
func animateLetter(){
print("tapped")
letterAnimView.timerWrite()
}
}
containing the View letterAnimView of Type LetterAnimationView:
import SwiftUI
struct LetterAnimationView: View {
#State var Robot : String = ""
let LETTER =
["alpha_be_1_81",
"alpha_be_1_82",
"alpha_be_1_83",
"alpha_be_1_84",
"alpha_be_1_85",
"alpha_be_1_86",
"alpha_be_1_87",
"alpha_be_1_88",
"alpha_be_1_89",
"alpha_be_1_90",
"alpha_be_1"]
var body: some View {
VStack(alignment:.center){
Image(Robot)
.resizable()
.frame(width: 80, height: 160, alignment: .center)
.onAppear(perform: timerWrite)
}
}
func timerWrite(){
var index = 0
let _ = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) {(Timer) in
Robot = LETTER[index]
print("one frame")
index += 1
if (index > LETTER.count - 1){
Timer.invalidate()
}
}
}
}
This gives me a fine animation, as coded in func timerWrite() and performed by .onAppear(perform: timerWrite).
After commenting //.onAppear(perform: timerWrite) I try animating by clicking
Button(action: animateLetter)
but nothing happens.
Maybe I got two different instances of letterAnimView, if so why?
Can anybody of you competent guys intentify my mistake?
Regards - Klaus
You don't want to store Views, they are structs so they are copied. Instead, create an ObservableObject to encapsulate this functionality.
I created RobotModel here with other minor changes:
class RobotModel: ObservableObject {
private static let atlas = [
"alpha_be_1_81",
"alpha_be_1_82",
"alpha_be_1_83",
"alpha_be_1_84",
"alpha_be_1_85",
"alpha_be_1_86",
"alpha_be_1_87",
"alpha_be_1_88",
"alpha_be_1_89",
"alpha_be_1_90",
"alpha_be_1"
]
#Published private(set) var imageName: String
init() {
imageName = Self.atlas.last!
}
func timerWrite() {
var index = 0
let _ = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] timer in
guard let self = self else { return }
self.imageName = Self.atlas[index]
print("one frame")
index += 1
if index > Self.atlas.count - 1 {
timer.invalidate()
}
}
}
}
struct AlphabetLetterDetail: View {
#StateObject private var robot = RobotModel()
let alphabetLetter: AlphabetLetter
var body: some View {
VStack {
Button(action: animateLetter) {
Image(uiImage: UIImage(named: "alpha_be_1")!)
.resizable()
.scaledToFit()
.frame(width: 60.0, height: 120.0)
}
LetterAnimationView(robot: robot)
}.navigationBarTitle(Text(verbatim: alphabetLetter.name), displayMode: .inline)
}
func animateLetter() {
print("tapped")
robot.timerWrite()
}
}
struct LetterAnimationView: View {
#ObservedObject var robot: RobotModel
var body: some View {
VStack(alignment:.center){
Image(robot.imageName)
.resizable()
.frame(width: 80, height: 160, alignment: .center)
.onAppear(perform: robot.timerWrite)
}
}
}

SwiftUI list deletion

I am facing problem, where i cannot bind Object properly in swiftUI. what I want to do is animate when user tap deletion.
ScrollView(.horizontal, showsIndicators: false) {
HStack{
VStack{
ZStack(alignment: .center) {
ForEach(viewModel.cards) { card in
let deleteAction: (Int) -> Void = { index in
withAnimation {
viewModel.deleteCard(at: index) // this is where i want to animate changes
}
}
StatusCardView(cardViewModel: card, editMode: $editMode, shrinked: $shrinked, scale: $scaleFactor, onDelete: deleteAction)
.animation(.spring(), value: viewModel.cards)
}
}
.frame(width: 1, height: 1)
Spacer()
}
Spacer()
}
}
viewModel:
class BubblesViewModel: ObservableObject {
#Published private(set) var cards = [CardViewModel]() // it is filled in constructor (not important right now)
func deleteCard(at index: Int) {
cards.removeAll(where: {index == $0.index})
freeCards = cards
cards.removeAll()
layoutCards()
}
}
StatusCardView:
struct StatusCardView: View {
...
var onDeleteClosure: ((Int) -> Void)
init(cardViewModel: CardViewModel, editMode: Binding<Bool>, shrinked: Binding<Bool>, scale: Binding<CGFloat>, onDelete: #escaping ((Int) -> Void)) {
...
self.onDeleteClosure = onDelete
}
var body: some View {
ZStack {
...
ZStack {
if editMode {
Text("-")
.font(type.titleFont)
.frame(width: 15, height: 15, alignment: .center)
.foregroundColor(.white)
.background(Color.Card.foregroundOutline)
.cornerRadius(50)
.opacity(editMode ? 1 : 0)
.onTapGesture {
onDeleteClosure(model.index!) // closure is called here to move logic to ViewModel
}
}
}
Spacer()
}
Spacer()
}
}
}
}
}
when I click delete nothing happens. animation is performed only on scroll. Any ideas what seems to be a problem ? edit: I updated post and added code for StatusCardView

Get a specific id in a modal

I'm still learning on the job and my question may seem stupid.
I've got a list of movies and on the tap I want to show card of the selected movie.
So I've got my ResultsView
var results:[DiscoverResult]
#State private var resultsCount:Int = 0
#State private var isPresented:Bool = false
#EnvironmentObject private var genres:Genres
var body: some View {
ScrollView {
ForEach (results){ result in
Button(action: {
isPresented.toggle()
}, label: {
ZStack {
ZStack {
KFImage(URL (string: baseUrlForThumb + result.posterPath)).resizable().scaledToFill()
.frame( height: 150)
.mask(Rectangle().frame( height: 150))
Rectangle().foregroundColor(.clear) // Making rectangle transparent
.background(LinearGradient(gradient: Gradient(colors: [.clear, .clear, .black]), startPoint: .top, endPoint: .bottom))
}.frame( height: 150)
// Titre du film
VStack(alignment: .center) {
Spacer()
Text(result.title)
.fontWeight(.bold)
.foregroundColor(.white)
.multilineTextAlignment(.center)
// Genres du film
Text(genres.generateGenresList(genreIDS: result.genreIDS)).font(.caption).foregroundColor(.white).multilineTextAlignment(.center)
} .padding()
}.padding(.horizontal)
})
.sheet(isPresented: $isPresented, content: {
MovieView(isPresented: $isPresented, movieId: result.id)
})
.navigationTitle(result.title)
}
}
}
}
And my MovieView
import SwiftUI
struct MovieView: View {
#Binding var isPresented: Bool
var movieId:Int
var body: some View {
VStack {
Text(String(movieId))
.padding()
Button("Fermer") {
isPresented = false
}
}
}
}
But the movie card still the same even list element selected.
I think that the 'result.id' is overwrite at every loop but i don't know how to fix it.
Sorry for my english mistakes.
thank for your purpose.
Instead of using isPresented for .sheet you can use .sheet(item:, content:) and pass the whole result object
.sheet(item: $selecteditem( { result in
MovieView(item: result)
}
To make this work you need a new property (you can remove isPresented)
#State private var selectedItem: DiscoverResult?
and you need update your MovieView struct
struct MovieView: View {
let result: DiscoverResult
var body: some View {
//...
}
}
or pass only the movie id to your MovieView if you prefer that.

SwiftUI ObservedObject in View has two references (instances) BUG

I do not why but I have very frustrating bug in my SwiftUI view.
This view has reference to ViewModel object. But this View is created multiple times on screen appear, and at the end the single View have multiple references to ViewModel object.
I reference this view model object in custom Binding setter/getter or in closure. But object references in Binding and in closure are totally different. This causes many problems with proper View refreshing or saving changes.
struct DealDetailsStagePicker : View {
// MARK: - Observed
#ObservedObject var viewModel: DealDetailsStageViewModel
// MARK: - State
/// TODO: It is workaround as viewModel.dealStageId doesn't work correctly
/// viewModel object is instantiated several times and pickerBinding and onDone
/// closure has different references to viewModel object
/// so updating dealStageId via pickerBinding refreshes it in different viewModel
/// instance than onDone closure executed changeDealStage() method (where dealStageId
/// property stays with initial or nil value.
#State var dealStageId: String? = nil
// MARK: - Binding
#Binding private var showPicker: Bool
// MARK: - Properties
let deal : Deal
// MARK: - Init
init(deal: Deal, showPicker: Binding<Bool>) {
self.deal = deal
self._showPicker = showPicker
self.viewModel = DealDetailsStageViewModel(dealId: deal.id!)
}
var body: some View {
let pickerBinding = Binding<String>(get: {
if self.viewModel.dealStageId == nil {
self.viewModel.dealStageId = self.dealStage?.id ?? ""
}
return self.viewModel.dealStageId!
}, set: { id in
self.viewModel.dealStageId = id //THIS viewModel is reference to object 0x8784783
self.dealStageId = id
})
return VStack(alignment: .leading, spacing: 4) {
Text("Stage".uppercased())
Button(action: {
self.showPicker = true
}) {
HStack {
Text("\(deal.status ?? "")")
Image(systemName: "chevron.down")
}
.contentShape(Rectangle())
}
}
.buttonStyle(BorderlessButtonStyle())
.adaptivePicker(isPresented: $showPicker, selection: pickerBinding, popoverSize: CGSize(width: 400, height: 200), popoverArrowDirection: .up, onDone: {
// save change
self.viewModel.changeDealStage(self.dealStages, self.dealStageId) // THIS viewModel references 0x92392983
}) {
ForEach(self.dealStages, id: \.id) { stage in
Text(stage.name)
.foregroundColor(Color("Black"))
}
}
}
}
I am experiencing this problem in multiple places writing SwiftUI code.
I have several workarounds:
1) as you can see here I use additional #State var to store dealStageId and pass it to viewModale.changeDealStage() instead of updating it on viewModal
2) in other places I am using Wrapper View around such view, then add #State var viewModel: SomeViewModel, then pass this viewModel and assign to #ObservedObject.
But this errors happens randomly depending on placement this View as Subview of other Views. Sometimes it works, sometime it does not work.
It is very od that SINGLE view can have references to multiple view models even if it is instantiated multiple times.
Maybe the problem is with closure as it keeps reference to first ViewModel instance and then this closure is not refreshed in adaptivePicker view modifier?
Workarounds around this issue needs many debugging and boilerplate code to write!
Anyone can help what I am doing wrong or what is wrong with SwiftUI/ObservableObject?
UPDATE
Here is the usage of this View:
private func makeDealHeader() -> some View {
VStack(spacing: 10) {
Spacer()
VStack(spacing: 4) {
Text(self.deal?.name ?? "")
Text(NumberFormatter.price.string(from: NSNumber(value: Double(self.deal?.amount ?? 0)/100.0))!)
}.frame(width: UIScreen.main.bounds.width*0.667)
HStack {
if deal != nil {
DealDetailsStagePicker(deal: self.deal!, showPicker: self.$showStagePicker)
}
Spacer(minLength: 24)
if deal != nil {
DealDetailsClientPicker(deal: self.deal!, showPicker: self.$showClientPicker)
}
}
.padding(.horizontal, 24)
self.makeDealIcons()
Spacer()
}
.frame(maxWidth: .infinity)
.listRowInsets(EdgeInsets(top: 0.0, leading: 0.0, bottom: 0.0, trailing: 0.0))
}
var body: some View {
ZStack {
Color("White").edgesIgnoringSafeArea(.all)
VStack {
self.makeNavigationLink()
List {
self.makeDealHeader()
Section(header: self.makeSegmentedControl()) {
self.makeSection()
}
}
....
UPDATE 2
Here is adaptivePicker
extension View {
func adaptivePicker<Data, ID, Content>(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> ForEach<Data, ID, Content>) -> some View where Data : RandomAccessCollection, ID: Hashable, Content: View {
self.modifier(AdaptivePicker2(isPresented: isPresented, selection: selection, popoverSize: popoverSize, popoverArrowDirection: popoverArrowDirection, onDone: onDone, content: content))
}
and here is AdaptivePicker2 view modifier implementation
struct AdaptivePicker2<Data, ID, RowContent> : ViewModifier, OrientationAdjustable where Data : RandomAccessCollection, ID: Hashable , RowContent: View {
// MARK: - Environment
#Environment(\.verticalSizeClass) var _verticalSizeClass
var verticalSizeClass: UserInterfaceSizeClass? {
_verticalSizeClass
}
// MARK: - Binding
private var isPresented: Binding<Bool>
private var selection: Binding<ID>
// MARK: - State
#State private var showPicker : Bool = false
// MARK: - Actions
private let onDone: (() -> Void)?
// MARK: - Properties
private let popoverSize: CGSize?
private let popoverArrowDirection: UIPopoverArrowDirection
private let pickerContent: () -> ForEach<Data, ID, RowContent>
// MARK: - Init
init(isPresented: Binding<Bool>, selection: Binding<ID>, popoverSize: CGSize? = nil, popoverArrowDirection: UIPopoverArrowDirection = .any, onDone: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> ForEach<Data, ID, RowContent>) {
self.isPresented = isPresented
self.selection = selection
self.popoverSize = popoverSize
self.popoverArrowDirection = popoverArrowDirection
self.onDone = onDone
self.pickerContent = content
}
var pickerView: some View {
Picker("Select State", selection: self.selection) {
self.pickerContent()
}
.pickerStyle(WheelPickerStyle())
.labelsHidden()
}
func body(content: Content) -> some View {
let isShowingBinding = Binding<Bool>(get: {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withAnimation {
self.showPicker = self.isPresented.wrappedValue
}
}
return self.isPresented.wrappedValue
}, set: {
self.isPresented.wrappedValue = $0
})
let popoverBinding = Binding<Bool>(get: {
self.isPresented.wrappedValue
}, set: {
self.onDone?()
self.isPresented.wrappedValue = $0
})
return Group {
if DeviceType.IS_ANY_IPAD {
if self.popoverSize != nil {
content.presentPopover(isShowing: popoverBinding, popoverSize: popoverSize, arrowDirection: popoverArrowDirection) { self.pickerView }
} else {
content.popover(isPresented: popoverBinding) { self.pickerView }
}
} else {
content.present(isShowing: isShowingBinding) {
ZStack {
Color("Dim")
.opacity(0.25)
.transition(.opacity)
.onTapGesture {
self.isPresented.wrappedValue = false
self.onDone?()
}
VStack {
Spacer()
// TEST: Text("Show Picker: \(self.showPicker ? "True" : "False")")
if self.showPicker {
VStack {
Divider().background(Color.white)
.shadow(color: Color("Dim"), radius: 4)
HStack {
Spacer()
Button("Done") {
print("Tapped picker done button!")
self.isPresented.wrappedValue = false
self.onDone?()
}
.foregroundColor(Color("Accent"))
.padding(.trailing, 16)
}
self.pickerView
.frame(height: self.isLandscape ? 120 : nil)
}
.background(Color.white)
.transition(.move(edge: .bottom))
.animation(.easeInOut(duration: 0.35))
}
}
}
.edgesIgnoringSafeArea(.all)
}
}
}
}
}
It seems new #StateObject from iOS 14 will solve this issue in SwiftUI.

My view moves up when I implemented the navigation link in swiftui

Mockup of the Application
Problem:
My application successfully navigates from one view to another without any complexities.When I use the navigationLink to navigate from View 4 to View 2 (refer mockup). The view 2 movesup. I tried debugging but I found no solution.
I have designed a mockup of what I am trying to acheive.
Code Block for View 4:
import SwiftUI
import BLE
struct View4: View {
#EnvironmentObject var BLE: BLE
#State private var showUnpairAlert: Bool = false
#State private var hasConnected: Bool = false
#State private var activateLink: Bool = false
let defaults = UserDefaults.standard
let defaultDeviceinformation = "01FFFFFFFFFF"
struct Keys {
static let deviceInformation = "deviceInformation"
}
var body: some View {
VStack(alignment: .center, spacing: 0) {
NavigationLink(destination: View2(), isActive: $activateLink,label: { EmptyView() })
// MARK: - Menu Bar
HStack(alignment: .center, spacing: 10) {
VStack(alignment: .center, spacing: 4) {
Text(self.hasConnected ? "PodId \(checkForDeviceInformation())":"Pod is not connected")
.font(.footnote)
.foregroundColor(.white)
Button(action: {
print("Unpair tapped!")
self.showUnpairAlert = true
}) {
HStack {
Text("Unpair")
.fontWeight(.bold)
.font(.body)
}
.frame(minWidth: 85, minHeight: 35)
.foregroundColor(.white)
.background(Color(red: 0.8784313725490196, green: 0.34509803921568627, blue: 0.36470588235294116))
.cornerRadius(30)
}
}
}
}
.alert(isPresented: $showUnpairAlert) {
Alert(title: Text("Unpair from \(checkForDeviceInformation())"), message: Text("Do you want to unpair the current pod?"), primaryButton: .destructive(Text("Unpair")) {
self.unpairAndSetDefaultDeviceInformation()
}, secondaryButton: .cancel())
}
}
func checkForDeviceInformation() -> String {
let deviceInformation = defaults.value(forKey: Keys.deviceInformation) as? String ?? ""
print("Device Info \(deviceInformation)")
return deviceInformation
}
func unpairAndSetDefaultDeviceInformation() {
defaults.set(defaultDeviceinformation, forKey: Keys.deviceInformation)
print("Pod unpaired and view changed to Onboarding")
DispatchQueue.main.async {
self.activateLink = true
}
}
}
Thank you !!!!