How to dynamically hide navigation back button in SwiftUI - swift

I need to temporarily hide the Back Button in a view during an asynchronous operation.
I want to prevent user from leaving the view before the operation completes.
It's possible to hide it permanently using .navigationBarBackButtonHidden(true).
But, then obviously user can't go back in this case, so they are stuck.
What am I missing?
Here is a contrived example to demonstrate:
struct TimerTest: View {
#State var isTimerRunning = false
var body: some View {
Button(action:self.startTimer) {
Text("Start Timer")
}
.navigationBarBackButtonHidden(isTimerRunning)
//.navigationBarBackButtonHidden(true) // This does hide it, but then it can't be unhidden.
}
func startTimer()
{
self.isTimerRunning = true
_ = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { timer in
print("Timer fired!")
self.isTimerRunning = false
}
}
}

Here is working solution. Back button cannot be hidden, it is managed by bar and owned by parent view, however it is possible to hide entire navigation bar with below approach.
Tested with Xcode 11.4 / iOS 13.4
struct ParentView: View {
#State var isTimerRunning = false
var body: some View {
NavigationView {
VStack {
NavigationLink("Go", destination: TimerTest(isTimerRunning: $isTimerRunning))
}
.navigationBarHidden(isTimerRunning)
.navigationBarTitle("Main") // << required, at least empty !!
}
}
}
struct TimerTest: View {
#Binding var isTimerRunning: Bool
var body: some View {
Button(action:self.startTimer) {
Text("Start Timer")
}
}
func startTimer()
{
self.isTimerRunning = true
_ = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { timer in
DispatchQueue.main.async { // << required !!
self.isTimerRunning = false
}
}
}
}

Related

SwiftUI: How to update child view with #Binding variable before updating state of parent view?

I have a basic setup to show another view over my ContentView using the code below.
struct ContentView: View {
#State var otherViewShowing: Bool = true
var body: some View {
if otherViewShowing {
OtherView(amIShowing: $otherViewShowing)
} else {
Text("Hello, world!")
}
}
}
When I set the "otherViewShowing" variable to false through changing "amIShowing" in the button in OtherView, though, the view abruptly disappears and the text for "Hello, world!" is shown immediately. I'm trying to get the OtherView to play the scaling down animation before updating the ContentView to show "Hello, world!" so it's a bit smoother.
struct OtherView: View {
#Binding var amIShowing: Bool
#State private var scaleAmount: CGFloat = 1
var body: some View {
VStack {
Button("Tap me") {
withAnimation(.default) {
amIShowing = false
scaleAmount = 0
}
}
}
.scaleEffect(scaleAmount)
}
}
Any thoughts on accomplishing this? Thank you in advance.
you can give duration and delay for this.
delay: Delays the animation for 2 seconds.
duration: Determines the duration of the animation.
VStack {
Button("Tap me") {
withAnimation(Animation.easeIn(duration: 2).delay(2)) {
amIShowing = false
scaleAmount = 0
}
}
}
.scaleEffect(scaleAmount)

View not refreshed when .onAppear runs SwiftUI

I have some code inside .onAppear and it runs, the problem is that I have to go to another view on the menu and then come back for the UI view to refresh. The code is kind of lengthy, but the main components are below, where mealData comes from CoreData and has some objects:
Go to the updated comment at the bottom for a simpler code example
VStack(alignment: .leading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 2) {
ForEach(mealData, id: \.self) { meal in
VStack(alignment: .leading) { ... }
.onAppear {
// If not first button and count < total amt of objects
if((self.settingsData.count != 0) && (self.settingsData.count < self.mealData.count)){
let updateSettings = Settings(context: self.managedObjectContext)
// If will_eat time isn't nill and it's time is overdue and meal status isn't done
if ((meal.will_eat != nil) && (IsItOverDue(date: meal.will_eat!) == true) && (meal.status! != "done")){
self.mealData[self.settingsData.count].status = "overdue"
print(self.mealData[self.settingsData.count])
if(self.settingsData.count != self.mealData.count-1) {
// "Breakfast": "done" = active - Add active to next meal
self.mealData[self.settingsData.count+1].status = "active"
}
updateSettings.count += 1
if self.managedObjectContext.hasChanges {
// Save the context whenever is appropriate
do {
try self.managedObjectContext.save()
} catch let error as NSError {
print("Error loading: \(error.localizedDescription), \(error.userInfo)")
}
}
}
}
}
}
}
}
}
Most likely since the UI is not refreshing automatically I'm doing something wrong, but what?
UPDATE:
I made a little example replicating what's going on, if you run it, and click on set future date, and wit 5 seconds, you'll see that the box hasn't changed color, after that, click on Go to view 2 and go back to view 1 and you'll see how the box color changes... that's what's happening above too:
import SwiftUI
struct ContentView: View {
#State var past = Date()
#State var futuredate = Date()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView())
{ Text("Go to view 2") }
Button("set future date") {
self.futuredate = self.past.addingTimeInterval(5)
}
VStack {
if (past < futuredate) {
Button(action: {
}) {
Text("")
}
.padding()
.background(Color.blue)
} else {
Button(action: {
}) {
Text("")
}
.padding()
.background(Color.black)
}
}
}
.onAppear {
self.past = Date()
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) var presentationMode: Binding
var body: some View {
Text("View 2")
}
}
You need to understand that all you put in body{...} is just an instruction how to display this view. In runtime system creates this View using body closure, gets it in memory like picture and pin this picture to struct with all the stored properties you define in it. Body is not a stored property. Its only instruction how to create that picture.
In your code, there goes init first. You didn't write it but it runs and sets all the structs property to default values = Date(). After that runs .onAppear closure which changes past value. And only then system runs body closure to make an image to display with new past value. And that's all. It is not recreating itself every second. You need to trigger it to check if the condition past < futuredate changed.
When you go to another view and back you do exactly that - force system to recreate view and that's why it checks condition again.
If you want View to change automatically after some fixed time spend, use
DispatchQueue.main.asyncAfter(deadline: .now() + Double(2)) {
//change #State variable to force View to update
}
for more complex cases you can use Timer like this:
class MyTimer: ObservableObject {
#Published var currentTimePublisher: Timer.TimerPublisher
var cancellable: Cancellable?
let interval: Double
var isOn: Bool{
get{if self.cancellable == nil{
return false
}else{
return true
}
}
set{if newValue == false{
self.stop()
}else{
self.start()
}
}
}
init(interval: Double, autoStart: Bool = true) {
self.interval = interval
let publisher = Timer.TimerPublisher(interval: interval, runLoop: .main, mode: .default)
self.currentTimePublisher = publisher
if autoStart{
self.start()
}
}
func start(){
if self.cancellable == nil{
self.currentTimePublisher = Timer.TimerPublisher(interval: self.interval, runLoop: .main, mode: .default)
self.cancellable = self.currentTimePublisher.connect()
}else{
print("timer is already started")
}
}
func stop(){
if self.cancellable != nil{
self.cancellable!.cancel()
self.cancellable = nil
}else{
print("timer is not started (tried to stop)")
}
}
deinit {
if self.cancellable != nil{
self.cancellable!.cancel()
self.cancellable = nil
}
}
}
struct TimerView: View {
#State var counter: Double
let timerStep: Double
#ObservedObject var timer: TypeTimer
init(timerStep: Double = 0.1, counter: Double = 10.0){
self.timerStep = timerStep
self._counter = State<Double>(initialValue: counter)
self.timer = MyTimer(interval: timerStep, autoStart: false)
}
var body: some View {
Text("\(counter)")
.onReceive(timer.currentTimePublisher) {newTime in
print(newTime.description)
if self.counter > self.timerStep {
self.counter -= self.timerStep
}else{
self.timer.stop()
}
}
.onTapGesture {
if self.timer.isOn {
self.timer.stop()
}else{
self.timer.start()
}
}
}
}
See the idea? You start timer and it will force View to update by sending notification with the Publisher. View gets that notifications with .onReceive and changes #State variable counter and that cause View to update itself. You need to do something like that.

SwiftUI: Published string changes inside view model are not updating view

I have a timer inside my view model class which every second changes two #Published strings inside the view model. View model class is an Observable Object which Observed by the view but the changes to these string objects are not updating my view.
I have a very similar structure in many other views(Published variables inside a ObservableObject which is observed by view) and it always worked. I can't seem to find what am I doing wrong?
ViewModel
final class QWMeasurementViewModel: ObservableObject {
#Published var measurementCountDownDetails: String = ""
#Published var measurementCountDown: String = ""
private var timer: Timer?
private var scheduleTime = 0
func setTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
DispatchQueue.main.async {
self.scheduleTime += 1
if self.scheduleTime == 1 {
self.measurementCountDownDetails = "Get ready"
self.measurementCountDown = "1"
}
else if self.scheduleTime == 2 {
self.measurementCountDownDetails = "Relax your arm"
self.measurementCountDown = "2"
}
else if self.scheduleTime == 3 {
self.measurementCountDownDetails = "Breathe"
self.measurementCountDown = "3"
}
else if self.scheduleTime == 4 {
self.measurementCountDownDetails = ""
self.measurementCountDown = ""
timer.invalidate()
}
}
}
}
}
View
struct QWMeasurementView: View {
#ObservedObject var viewModel: QWMeasurementViewModel
var body: some View {
VStack {
Text(viewModel.measurementCountDownDetails)
.font(.body)
Text(viewModel.measurementCountDown)
.font(.title)
}
.onAppear {
viewModel.setTimer()
}
}
}
Edit
After investigation, this seems to be related to how it is being presented. Cause if it's a single view this code works but I am actually presenting this as a sheet. (Still cannot understand why would it make a difference..)
struct QWBPDStartButtonView: View {
#ObservedObject private var viewModel: QWBPDStartButtonViewModel
#State private var startButtonPressed: Bool = false
init(viewModel: QWBPDStartButtonViewModel) {
self.viewModel = viewModel
}
var body: some View {
Button(action: {
self.startButtonPressed = true
}) {
ZStack {
Circle()
.foregroundColor(Color("midGreen"))
Text("Start")
.font(.title)
}
}
.buttonStyle(PlainButtonStyle())
.sheet(isPresented: $startButtonPressed) {
QWMeasurementView(viewModel: QWMeasurementViewModel())
}
}
}
You’re passing in a brand new viewmodel to the sheet’s view.
Try passing in the instance from line 3

SwiftUI instanced #State variable

I am quite new to SwiftUI. I have a following "Counter" view that counts up every second. I want to "reset" the counter when the colour is changed:
struct MyCounter : View {
let color: Color
#State private var count = 0
init(color:Color) {
self.color = color
_count = State(initialValue: 0)
}
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
Here is my main view that uses counter:
struct ContentView: View {
#State var black = true
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow)
Button(action:{self.black.toggle()}) { Text("Toggle") }
}
}
}
When i click "Toggle" button, i see MyCounter constructor being called, but #State counter persists and never resets. So my question is how do I reset this #State value? Please note that I do not wish to use counter as #Binding and manage that in the parent view, but rather MyCounter be a self-contained widget. (this is a simplified example. the real widget I am creating is a sprite animator that performs sprite animations, and when I swap the image, i want the animator to start from frame 0). Thanks!
There are two way you can solve this issue. One is to use a binding, like E.Coms explained, which is the easiest way to solve your problem.
Alternatively, you could try using an ObservableObject as a view model for your timer. This is the more flexible solution. The timer can be passed around and it could also be injected as an environment object if you so desire.
class TimerModel: ObservableObject {
// The #Published property wrapper ensures that objectWillChange signals are automatically emitted.
#Published var count: Int = 0
init() {}
func start() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
func reset() {
count = 0
}
}
Your timer view then becomes
struct MyCounter : View {
let color: Color
#ObservedObject var timer: TimerModel
init(color: Color, timer: TimerModel) {
self.color = color
self.timer = timer
}
var body: some View {
Text("\(timer.count)").foregroundColor(color)
.onAppear(){
self.timer.start()
}
}
}
Your content view becomes
struct ContentView: View {
#State var black = true
#ObservedObject var timer = TimerModel()
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow, timer: self.timer)
Button(action: {
self.black.toggle()
self.timer.reset()
}) {
Text("Toggle")
}
}
}
}
The advantage of using an observable object is that you can then keep track of your timer better. You could add a stop() method to your model, which invalidates the timer and you can call it in a onDisappear block of your view.
One thing that you have to be careful about this approach is that when you're using the timer in a standalone fashion, where you create it in a view builder closure with MyCounter(color: ..., timer: TimerModel()), every time the view is rerendered, the timer model is replaced, so you have to make sure to keep the model around somehow.
You need a binding var:
struct MyCounter : View {
let color: Color
#Binding var count: Int
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
struct ContentView: View {
#State var black = true
#State var count : Int = 0
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow , count: $count)
Button(action:{self.black.toggle()
self.count = 0
}) { Text("Toggle") }
}
}
}
Also you can just add one State Value innerColor to help you if you don't like binding.
struct MyCounter : View {
let color: Color
#State private var count: Int = 0
#State private var innerColor: Color?
init(color: Color) {
self.color = color
}
var body: some View {
return Text("\(self.count)")
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}.foregroundColor(color).onReceive(Just(color), perform: { color in
if self.innerColor != self.color {
self.count = 0
self.innerColor = color}
})
}
}

Correct way to present an ActionSheet with SwiftUI

I want to show ActionSheet (or any other modal, but Alert) on some event like button tap.
I found the way of doing it using the state variable. It seems a little bit strange for me to display it that way because I have to reset variable when ActionSheet closes manually.
Is there a better way to do it?
Why there is a separate method for presenting Alert that allows you to bind its visibility to a state variable? What's the difference with my approach?
struct Sketch : View {
#State var showActionSheet = false
var body: some View {
ZStack {
Button(action: { showActionSheet = true }) { Text("Show") }
}
.presentation(showActionSheet ?
ActionSheet(
title: Text("Action"),
buttons: [
ActionSheet.Button.cancel() {
self. showActionSheet = false
}
])
: nil)
}
}
Enforcing the preference for the state variable approach, Apple has adopted for the alert and action sheet APIs. For the benefit of others who find this question, here are updated examples of all 3 types based on Xcode 11 beta 7, iOS 13.
#State var showAlert = false
#State var showActionSheet = false
#State var showAddModal = false
var body: some View {
VStack {
// ALERT
Button(action: { self.showAlert = true }) {
Text("Show Alert")
}
.alert(isPresented: $showAlert) {
// Alert(...)
// showAlert set to false through the binding
}
// ACTION SHEET
Button(action: { self.showActionSheet = true }) {
Text("Show Action Sheet")
}
.actionSheet(isPresented: $showActionSheet) {
// ActionSheet(...)
// showActionSheet set to false through the binding
}
// FULL-SCREEN VIEW
Button(action: { self.showAddModal = true }) {
Text("Show Modal")
}
.sheet(isPresented: $showAddModal, onDismiss: {} ) {
// INSERT a call to the new view, and in it set showAddModal = false to close
// e.g. AddItem(isPresented: self.$showAddModal)
}
}
For the modal part of your question, you could use a PresentationButton:
struct ContentView : View {
var body: some View {
PresentationButton(Text("Click to show"), destination: DetailView())
}
}
Source
struct Sketch : View {
#State var showActionSheet = false
var body: some View {
ZStack {
Button(action: { self.showActionSheet.toggle() }) { Text("Show") }
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(title: Text("Test"))
}
}
}
}
This would work, a #State is a property wrapper and your action sheet will keep an eye on it, whenever it turns to true, the action sheet will be shown
Below is the best way to show multiple action sheets according to requirements.
struct ActionSheetBootCamp: View {
#State private var isShowingActionSheet = false
#State private var sheetType: SheetTypes = .isOtherPost
enum SheetTypes {
case isMyPost
case isOtherPost
}
var body: some View {
HStack {
Button("Other Post") {
sheetType = .isOtherPost
isShowingActionSheet.toggle()
}
Button("My post") {
sheetType = .isMyPost
isShowingActionSheet.toggle()
}
}.actionSheet(isPresented: $isShowingActionSheet, content: getActionSheet)
}
func getActionSheet() -> ActionSheet {
let btn_share: ActionSheet.Button = .default(Text("Share")) {
//Implementation
}
let btn_report: ActionSheet.Button = .destructive(Text("Report")) {
//Implementation
}
let btn_edit: ActionSheet.Button = .default(Text("Edit")) {
//Implementation
}
let btn_cancel: ActionSheet.Button = .cancel()
switch(sheetType) {
case .isMyPost:
return ActionSheet(title: Text("This is the action sheet title"), message: nil, buttons: [btn_share,btn_edit, btn_cancel])
case .isOtherPost:
return ActionSheet(title: Text("This is the action sheet title"), message: nil, buttons: [btn_share,btn_report, btn_cancel])
}
}
}