Dismiss a View in SwiftUI when parent is re-rendered - swift

Using iOS14.4, Swift5.3.2, XCode12.2,
I try to dismiss a SwiftUI's GridView (see below code).
The dismiss function is done by the \.presentationMode property of the #Environment as explained here.
Everything works until the moment where I introduced a #Binding property that mutates the parent-View at the very moment of the dismissal. (see dataStr = titles[idx] in code excerpt below).
I read that dismissal by \.presentationMode only works if the parent-View is not updated during the time the child-View is shown.
But I absolutely need to cause a mutation on the parent-View when the user taps on an element of the GridView at play here.
How can I re-write so that parent-View is updated AND dismissal of Child-View still work ?
struct GridView: View {
#Environment(\.presentationMode) private var presentationMode
#Binding var dataStr: String
#State private var titles = [String]()
let layout = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
ScrollView {
LazyVGrid(columns: layout, spacing: 10) {
ForEach(titles.indices, id: \.self) { idx in
VStack {
Text(titles[idx])
Image(titles[idx])
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: (UIScreen.main.bounds.width / 2) - 40)
}
.onTapGesture {
// WITHOUT THIS LINE OF CODE - EVERYTHING WORKS. WHY???????????????
dataStr = titles[idx]
self.presentationMode.wrappedValue.dismiss()
}
}
}
.padding()
}
}
}
As #jnpdx asked, here you can see the parent-View. Please find the GridView(dataStr: self.$dataStr) inside the .sheet() of the ToolBarItem()....
import SwiftUI
struct MainView: View {
#EnvironmentObject var mediaViewModel: MediaViewModel
#EnvironmentObject var commService: CommunicationService
#State private var dataStr = ""
#State private var connectionsLabel = ""
#State private var commumincationRole: THRole = .noMode
#State private var showingInfo = false
#State private var showingGrid = false
init() {
UINavigationBar.appearance().tintColor = UIColor(named: "title")
}
var body: some View {
NavigationView {
if mediaViewModel.mediaList.isEmpty {
LoadingAnimationView()
.navigationBarHidden(true)
.ignoresSafeArea()
} else {
if dataStr.isEmpty {
MainButtonView(dataStr: $dataStr,
commumincationRole: $commumincationRole,
connectionsLabel: $connectionsLabel
)
.navigationBarHidden(false)
.navigationTitle("Trihow Pocket")
.navigationBarColor(backgroundColor: UIColor(named: "btnInactive"), titleColor: UIColor(named: "title"))
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
showingInfo.toggle()
}) {
Image(systemName: "ellipsis")
}
.sheet(isPresented: $showingInfo) {
InfoView()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingGrid.toggle()
}) {
Image(systemName: "square.grid.3x3")
}
.sheet(isPresented: $showingGrid) {
// GRIDVIEW CALLING THE CHILD-VIEW IS HERE !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
GridView(dataStr: self.$dataStr)
}
}
}
} else {
let str = self.dataStr
#if os(iOS)
PageViewiOS(dataStr: self.$dataStr, commumincationRole: $commumincationRole)
.navigationBarHidden(true)
.onAppear() {
if commumincationRole == .moderatorMode {
commService.send(thCmd: THCmd(key: .tagID, sender: "", content: str))
}
}
.ignoresSafeArea()
#elseif os(macOS)
PageViewMacOS()
.ignoresSafeArea()
#endif
}
}
}
.onTHComm_PeerAction(service: commService) { (peers) in
let idsOrNames = peers.map { (peer) -> String in
if let id = peer.id {
return "\(id)"
} else if let name = peer.name {
return "\(name)"
} else {
return ""
}
}
connectionsLabel = "Connected devices: \n\(idsOrNames.lineFeedString)"
}
.onTHComm_ReceiveCmd(service: commService) { (thCmd) in
if (commumincationRole == .moderatorMode) || (commumincationRole == .discoveryMode) {
switch thCmd.key {
case .tagID:
dataStr = thCmd.content
case .closeID:
dataStr = ""
default:
break
}
}
}
.onTHComm_LastMessageLog(service: commService) { (log) in
print(log)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
.environmentObject(MediaViewModel())
.environmentObject(MultipeerConnectivityService())
}
}

With the help of #jnpdx, I found a workaround.
Wrap the binding-property (i.e. dataStr in my example) into a delayed block that executes after something like 50ms:
.onTapGesture {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
dataStr = thumNames[idx]
}
self.presentationMode.wrappedValue.dismiss()
}
Of course, this workaround only works in my case, because I do no longer need to keep the Child-View open. There might be other situations where the Parent-View needs to be updated prior to closing the Child-View (i.e. here the update of dataStr can be done right at the closing moment of the Child-View).
I am still wondering how to deal with dismiss-problems for any case where the Child-View makes the Parent-View update prior to closing. These are situations where SwiftUI's dismiss function no longer work from then on. Any mutation of the Parent-View cause the Child-View to separate somehow and dismisss no longer works.
Any idea what to do in that case ?

Related

How to assign value to #State in View from ViewModel?

I have a movie listing view with basic listing functionality, Once pagination reaches to the last page I want to show an alert for that I am using reachedLastPage property.
The viewModel.state is an enum, the case movies has associated value in which there is moreRemaining property which tells if there are more pages or not.
Once the moreRemaining property becomes false I want to make reachedLastPage to true so that I can show an alert.
How can I achieve this in best way?
import SwiftUI
import SwiftUIRefresh
struct MovieListingView<T>: View where T: BaseMoviesListViewModel {
#ObservedObject var viewModel: T
#State var title: String
#State var reachedLastPage: Bool = false
var body: some View {
NavigationView {
ZStack {
switch viewModel.state {
case .loading:
LoadingView(title: "Loading Movies...")
.onAppear {
fetchMovies()
}
case .error(let error):
ErrorView(message: error.localizedDescription, buttonTitle: "Retry") {
fetchMovies()
}
case .noData:
Text("No data")
.multilineTextAlignment(.center)
.font(.system(size: 20))
case .movies(let data):
List {
ForEach(data.movies) { movie in
NavigationLink(destination: LazyView(MovieDetailView(viewModel: MovieDetailViewModel(id: movie.id)))) {
MovieViewRow(movie: movie)
.onAppear {
if movie == data.movies.last && data.moreRemaining {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
fetchMovies()
}
}
}
}
if movie == data.movies.last && data.moreRemaining {
HStack {
Spacer()
ActivityIndicator(isAnimating: .constant(data.moreRemaining))
Spacer()
}
}
}
}.pullToRefresh(isShowing: .constant(data.isRefreshing)) {
print("Refresheeeee")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
refreshMovies()
}
}
}
}
.navigationViewStyle(.stack)
.navigationBarTitle("\(title)", displayMode: .inline)
.alert(isPresented: $reachedLastPage) {
Alert(title: Text("You have reached to the end of the list."))
}
}
}
private func fetchMovies() {
viewModel.trigger(.fetchMovies(false))
}
private func refreshMovies() {
viewModel.trigger(.fetchMovies(true))
}
}
you could try this approach, using .onReceive(...). Add this to your
ZStack or NavigationView:
.onReceive(Just(viewModel.moreRemaining)) { val in
reachedLastPage = !val
}
Also add: import Combine
(Ignoring "the best way" part, 'cause it's opinion-based,) one way to achieve that is to make your view model an observable object (which likely already is), adding the publisher of reachedLastPage there, and observe it directly from the view. Something like this:
final class ContentViewModel: ObservableObject {
#Published var reachedLastPage = false
init() {
// Just an example of changing the value.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { self.reachedLastPage = true }
}
}
struct ContentView: View {
var body: some View {
Text("Hello World")
.alert(isPresented: $viewModel.reachedLastPage) {
Alert(title: Text("Alert is triggered"))
}
}
#ObservedObject private var viewModel: ContentViewModel
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
}
Once reachedLastPage takes the true value, the alert will be presented.

PopUp don't want to dismiss SwiftUI

I am having a problem while I want to dismiss a popup (that appears automatically depending on a specific condition) by clicking a button.
This is the PopUp struct:
struct dataPrivacyPopUp: View {
let model: OffersView.Model
let termsOfUseText = "Nutzungsbedingungen"
let privacyPolicyText = "Datenschutzerklärung"
#State var termsOfUseChecked = false
#State var privacyPolicyChecked = false
#State var buttonDisabled = true
#State private var showPopUp: Bool = false
#Binding var showModal: Bool
var body: some View {
ZStack {
if ( model.showPopUp == true) {
// PopUp Window
VStack(alignment: .center){
Image("logo")
.aspectRatio(contentMode: .fill)
.frame(alignment: .center)
.padding()
VStack(alignment: .leading) {
Text((model.acceptance?.salutation)!)
.multilineTextAlignment(.leading)
.padding()
.foregroundColor(Color.black)
Text((model.acceptance?.statement)!)
.multilineTextAlignment(.leading)
.padding()
.foregroundColor(Color.black)
Text((model.acceptance?.declarationIntro)!)
.multilineTextAlignment(.leading)
.padding()
.foregroundColor(Color.black)
if ((model.acceptance?.dpr)! == true) {
VStack(alignment: .leading){
HStack {
CheckBoxView(checked: $privacyPolicyChecked)
HStack(spacing: 0){
Text(R.string.localizable.dataPrivacyPopupText())
.foregroundColor(Color.black)
Button(privacyPolicyText) {
model.openUrl(url: API.privacyPolicyURL)
}
}
}
Text((model.acceptance?.declarationOutro)!)
.multilineTextAlignment(.leading)
.padding()
}
.padding()
Button(action: {
model.setTos()
print("showModal PopUpView2 1: \(showModal)")
self.showModal.toggle()
print("showModal PopUpView2 2: \(showModal)")
}, label: {
Text(R.string.localizable.dataPrivacyButton())
.foregroundColor(Color.white)
.font(Font.system(size: 23, weight: .semibold))
})
.disabled(model.buttonDisabledForOne(privacyPolicyChecked: privacyPolicyChecked, termsOfUseChecked: termsOfUseChecked))
.padding()
}
}
}
// .onAppear(perform: )
.background(Color.white01)
.padding()
}
}
}
}
and this is where I call it (contentView):
struct OffersView: View {
#StateObject var model = Model()
#State private var showingPopUp = false
#State private var showModal = false
#State private var showingAddUser = false
// var showPopup : Bool = true
var body: some View {
NavigationView {
Group {
switch model.sections {
case .loading:
ActivityIndicator(animate: true)
case .success(let sections):
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 0) {
Text(R.string.localizable.offersHello(model.firstName))
.aplFont(.headline02)
.padding(.bottom, 24)
VStack(spacing: 48) {
ForEach(sections) { section in
OffersSectionView(section: section, model: model)
}
}
}
.useFullWidth(alignment: .leading)
.padding()
}
default:
Color.clear
if ( model.showPopUp == true) {
ZStack {
Color.black.opacity(model.showPopUp ? 0.3 : 0).edgesIgnoringSafeArea(.all)
dataPrivacyPopUp(model: model, showModal: self.$showModal)
.onAppear(perform: {
self.showModal.toggle()
})
}
}
}
}
.navigationBarHidden(true)
.handleNavigation(model.navigationPublisher)
.onAppear(perform: model.onAppear)
.onDisappear(perform: model.onDisappear)
.environment(\.dynamicTypeEnabled, false)
.safariView(isPresented: model.showSafari) {
SafariView(url: model.safariUrl!)
}
}
}
}
I need help about this, I tried the traditional method to set a #Binding variable etc .. but that's not working, the boolean value is changing but the UI is not updating (the popup is not dismissing), thank you
I tried to look at your code - I suggest you simplify it to the bare minimum to exemplify your issue - and it seems that you are using 2 properties to show your pop-up: showingPopUp and showModal. It is quite likely that you are having trouble keeping them both in sync.
For starters, I would suggest to use only one variable, either it is true or false - "a man with two watches never knows what time it is".
For the solution:
If you prefer keeping your ZStack approach, the solution would look something like:
struct MyPrivacy: View {
#Binding var showMe: Bool
var body: some View {
VStack {
Text("The content of the pop-up")
.padding()
Button {
withAnimation {
showMe.toggle()
}
} label: {
Text("Dismiss")
}
}
}
}
struct Offers: View {
#State private var showPopup = false
var body: some View {
NavigationView {
ZStack {
VStack {
Text("View behind the pop-up")
.padding()
Button {
withAnimation {
showPopup.toggle()
}
} label: {
Text("Pop")
}
}
if showPopup {
Color.white
MyPrivacy(showMe: $showPopup)
}
}
}
}
}
If instead you want to go for a more flexible approach, if you are developing for iOS, SwiftUI has a convenient object - Sheets. You can use it as suggested in the documentation, or build a specific struct that manages all the modal views of this type and use your model to handle the presentation.
The process goes like:
Create a struct that will handle all kinds of Sheets of your app.
Add to your view-model the property to present any sheet.
Create the Views that will be the content of each sheet.
Call the .sheet(item:content:) method on each View the requires a sheet.
Here's the sample code:
SheetView handler:
struct SheetView: Identifiable {
// This struct controls what modal view will be presented.
// The enum SheetScreenType can grow to as many as different
// modal views your app needs - add the content in the switch below.
let id = UUID()
var screen: SheetScreenType
#ViewBuilder
var content: some View {
switch screen {
case .dataPrivacy:
DataPrivacy()
default:
EmptyView()
}
}
enum SheetScreenType {
case dataPrivacy
case none
}
}
Presenter in your view-model:
class MyViewModel: ObservableObject {
// This code can fit anywhere within your view-model.
// It controls the presentation of the modal view, which in
// this case is a Sheet.
private let sharedSheet = SheetView(screen: .none)
// Show the selected sheet
#Published var sheetView: SheetView?
var showSheet: SheetView.SheetScreenType {
get {
return sheetView?.screen ?? .none
}
set {
switch newValue {
case .none:
sheetView = nil
default:
sheetView = sharedSheet
}
sheetView?.screen = newValue
}
}
}
Content of your modal view:
struct DataPrivacy: View {
#EnvironmentObject var model: MyViewModel // Pass YOUR model here
var body: some View {
VStack(alignment: .center){
Text("Respecting your privacy, no details are shown here")
.padding()
Button {
print("Anything you need")
// Set the showSheet property of your model to
// present a modal view. Setting it to .none dismisses
// the modal view.
model.showSheet = .none
} label: {
Text("Time do dismiss the modal view")
}
.padding()
}
}
}
Enable your view to listen to your model to present the sheet:
struct OffersView: View {
#ObservedObject var model = MyViewModel() // Pass YOUR model here
var body: some View {
VStack {
Text("Anything you wish")
.padding()
Button {
withAnimation {
// Set the showSheet property of your model to
// present a modal view. Set it to any choice
// among the ones in the SheetScreen.SheetScreenType enum.
model.showSheet = .dataPrivacy
}
} label: {
Text("Tap here for the privacy in modal view")
}
}
// Show a modal sheet.
// Add this property at the top level of every view that
// requires a modal view presented - whatever content it might have.
.sheet(item: $model.sheetView) { sheet in
sheet.content
.environmentObject(model)
}
}
}
Good luck with your project!

Extracting Observed Object into subview behaves differently than just one view struct. Why?

This doesn't work the way I intended it…
When an error occurs in MyClass instance the user is advised to check the settings app and then come back to my app. The next time my app is opened it should just retry by initializing MyClass all over again. If the error persists it will just again display above everything else. (Actually I would like to just fatalError() my app but that isn't best practice, is it?) So I thought I just initialize a new instance of MyClass…
class MyClass: ObservableObject {
static var shared = MyClass()
#Published var errorMsg: String? = nil
func handleError() -> Void {
DispatchQueue.main.async {
self.errorMsg = "Sample Error Message"
}
}
init() {
self.errorMsg = nil
}
}
struct ContentView: View {
#ObservedObject var theObj = MyClass.shared
#Environment(\.scenePhase) private var scenePhase
var body: some View {
ZStack {
VStack {
Text("App when everything is fine")
.onTapGesture {
MyClass.shared.handleError()
}
}
VStack {
if theObj.errorMsg != nil {
VStack {
Spacer()
HStack {
Spacer()
Text(theObj.errorMsg!)
.font(.footnote)
.onTapGesture {
print("theObj.errorMsg! = \(theObj.errorMsg!)")
}
Spacer()
}
Spacer()
}
.background(Color.red)
}
}
}
.onChange(of: scenePhase) { phase in
switch phase {
case .active, .inactive:
if (MyClass.shared.errorMsg != nil) {
MyClass.shared = MyClass()
print("Error cancelled. Retry at next launch...")
}
default:
()
}
}
}
}
As I said: this doesn't work.
Very surprisingly the following variation works… I thought it couldn't work like this but it does. My big question is: WHY does it work like this? Shouldn't this be the same thing? What's the difference that I don't see?
struct ContentView: View {
#Environment(\.scenePhase) private var scenePhase
var body: some View {
ZStack {
VStack {
Text("App when everything is fine")
.onTapGesture {
MyClass.shared.handleError()
}
}
ErrorMsgView()
}
.onChange(of: scenePhase) { phase in
switch phase {
case .active, .inactive:
if (MyClass.shared.errorMsg != nil) {
MyClass.shared = MyClass()
print("Error cancelled. Retry at next launch...")
}
default:
()
}
}
}
}
struct ErrorMsgView: View {
#ObservedObject var theObj = MyClass.shared
var body: some View {
VStack {
if theObj.errorMsg != nil {
VStack {
Spacer()
HStack {
Spacer()
Text(theObj.errorMsg!)
.font(.footnote)
.onTapGesture {
print("theObj.errorMsg! = \(theObj.errorMsg!)")
}
Spacer()
}
Spacer()
}
.background(Color.red)
}
}
}
}
Also I honestly don't understand how do I conclusively kill the MyClass instance I don't need anymore? I do know how to terminate the background tasks that MyClass is running, but is it sufficient to just assign a new instance to the static var shared and the old one is purged?

SwiftUI nested LazyVStacks in a single ScrollView

I'm trying to build a comment thread. So top level comments can all have nested comments and so can they and so on and so forth. But I'm having issues around scrolling and also sometimes when expanding sections the whole view just jumps around, and can have a giant blank space at the bottom. The code looks like this:
struct ContentView: View {
var body: some View {
VStack {
HStack {
Text("Comments")
.font(.system(size: 34))
.fontWeight(.bold)
Spacer()
}
.padding()
CommentListView(commentIds: [0, 1, 2, 3], nestingLevel: 1)
}
}
}
struct CommentListView: View {
let commentIds: [Int]?
let nestingLevel: Int
var body: some View {
if let commentIds = commentIds {
LazyVStack(alignment: .leading) {
ForEach(commentIds, id: \.self) { id in
CommentItemView(viewModel: CommentItemViewModel(commentId: id), nestingLevel: nestingLevel)
}
}
.applyIf(nestingLevel == 1) {
$0.scrollable()
}
} else {
Spacer()
Text("No comments")
Spacer()
}
}
}
struct CommentItemView: View {
#StateObject var viewModel: CommentItemViewModel
let nestingLevel: Int
#State private var showComments = false
var body: some View {
VStack {
switch viewModel.viewState {
case .error:
Text("Error")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .loading:
Text("Loading")
.fontWeight(.thin)
.font(.system(size: 12))
.italic()
case .complete:
VStack {
Text(viewModel.text)
.padding(.bottom)
.padding(.leading, 20 * CGFloat(nestingLevel))
if let commentIds = viewModel.commentIds {
Button {
withAnimation {
showComments.toggle()
}
} label: {
Text(showComments ? "Hide comments" : "Show comments")
}
if showComments {
CommentListView(commentIds: commentIds, nestingLevel: nestingLevel + 1)
}
}
}
}
}
}
}
class CommentItemViewModel: ObservableObject {
#Published private(set) var text = ""
#Published private(set) var commentIds: [Int]? = [0, 1, 2, 3]
#Published private(set) var viewState: ViewState = .loading
private let commentId: Int
private var viewStateInternal: ViewState = .loading {
willSet {
withAnimation {
viewState = newValue
}
}
}
init(commentId: Int) {
self.commentId = commentId
fetchComment()
}
private func fetchComment() {
viewStateInternal = .complete
text = CommentValue.allCases[commentId].rawValue
}
}
Has anyone got a better way of doing this? I know List can now accept a KeyPath to child object and it can nest that way, but there's so limited design control over List that I didn't want to use it. Also, while this code is an example, the real code will have to load each comment from an API call, so List won't perform as well as LazyVStack in that regard.
Any help appreciated - including a complete overhaul of how to implement this sort of async loading nested view.

persistent value in a picker changing views in SwiftUI

I don't understand how implementing in SwiftUI a simple picker showing a list of values that retain the selected value switching between different views. I'm able to use the selected value to update the Model via Combine framework by the way.
here's the code, but the onAppear{}/onDisappear{} doesn't work as expected:
struct CompanyView: View {
#ObservedObject var dataManager: DataManager = DataManager.shared
#State var selTipoAzienda = 0
var body: some View {
VStack {
companyPhoto
Text("Company view")
Form {
Picker(selection: $selTipoAzienda, label: Text("Tipo Azienda")) {
ForEach(0 ..< self.dataManager.company.tipoAziendaList.count) {
Text(self.dataManager.company.tipoAziendaList[$0])
}
}
}
Button(action: {self.dataManager.cambiaTipoAzienda(tipoAzienda: self.dataManager.company.tipoAziendaList[self.selTipoAzienda]) }) {
Image(systemName: "info.circle.fill")
.font(Font.system(size: 28))
.padding(.horizontal, 16)
}
}
// .onAppear{
// self.selTipoAzienda = self.dataManager.company.tipoAziendaList.firstIndex(of: self.dataManager.company.tipoAzienda) ?? 0
// }
// .onDisappear{
// self.dataManager.cambiaTipoAzienda(tipoAzienda: self.dataManager.company.tipoAziendaList[self.selTipoAzienda])
// }
}
I think binding and didSet would be the answer but I don't know how they have to be implemented
The provided code is not compilable/testable, so below just shows an approach (see also comments inline)
struct CompanyView: View {
#ObservedObject var dataManager: DataManager = DataManager.shared
#State var selTipoAzienda: Int
init() {
// set up initial value from persistent data manager
_selTipoAzienda = State(initialValue: self.dataManager.company.tipoAziendaList.firstIndex(of: self.dataManager.company.tipoAzienda) ?? 0)
}
var body: some View {
// inline proxy binding to intercept Picker changes
let boundSelTipoAzienda = Binding(get: { self.selTipoAzienda }, set: {
self.selTipoAzienda = $0
// store selected value into data manager
self.dataManager.cambiaTipoAzienda(tipoAzienda: self.dataManager.company.tipoAziendaList[$0])
})
return VStack {
companyPhoto
Text("Company view")
Form {
Picker(selection: boundSelTipoAzienda, label: Text("Tipo Azienda")) {
ForEach(0 ..< self.dataManager.company.tipoAziendaList.count) {
Text(self.dataManager.company.tipoAziendaList[$0])
}
}
}
Button(action: {self.dataManager.cambiaTipoAzienda(tipoAzienda: self.dataManager.company.tipoAziendaList[self.selTipoAzienda]) }) {
Image(systemName: "info.circle.fill")
.font(Font.system(size: 28))
.padding(.horizontal, 16)
}
}
}
}