SwiftUI How to trigger action inside View when view appers / dissapears? - swiftui

I have recently started dabbling in swiftUI. One of the thing that I have noticed is that now we have option to add modifier on View: .onAppear and .onDisappear, and trigger some action. But what I want to do is somewhat different, I want to trigger action inside the View when View appears / disappears.
For example I have video player and every time inside List view scrolls from the screen, I want to pause video (Video is embedded inside my view). And vice versa I want video to start playing. All logic for video playing / pausing is inside the view.
So how can I trigger action when view appears / disappears?
Here is my code example:
struct ContentView: View {
var body: some View {
VStack {
List(0..<14) { item in
VideoPreviewView(videoURLString: largeVideos[item])
.frame(height: 375)
.background(Color.red)
.onAppear {
// play video
}
.onDisappear {
// stop video
}
}
}
}
}
Now how do I pass VideoPreviewView these actions, player is inside the VideoPreviewView?

In order to run onAppear logic, when a child view appears you simply attach the .onAppear function inside the child views body.
struct VideoPreviewView: View {
let videoURLString: String
var body: some View {
Text("VideoPreviewView")
.onAppear {
playVideo()
}
.onDisappear {
stopVideo()
}
}
private func playVideo() {
print("Start playing \(videoURLString)")
}
private func stopVideo() {
print("Stop playing \(videoURLString)")
}
}
struct ContentView: View {
let videoUrls = ["1","2","3", "4","5", "6", "7"]
var body: some View {
VStack {
List(videoUrls, id: \.self) { urlString in
LazyVStack {
VideoPreviewView(videoURLString: urlString)
.frame(height: 375)
.background(Color.red)
}
}
}
}
}
Which yields the following output in the console:
Start playing 1
Start playing 2
Start playing 3
Start playing 4
Start playing 5
Start playing 6
Start playing 7
Stop playing 4
Stop playing 5
Stop playing 6
Stop playing 7
All onAppear methods of the child views are called, and right after the the onDisappear methods of the non-visible child views are called.
You can find a discussion on whats happening here: https://www.hackingwithswift.com/forums/swiftui/onappear-something-i-don-t-understand/1978
If you use a ScrollView + ForEach + LazyVStack (this one is important) combination, you get the following output:
struct ContentView: View {
let videoUrls = ["1","2","3", "4","5", "6", "7"]
var body: some View {
ScrollView {
ForEach(videoUrls, id: \.self) { urlString in
LazyVStack {
VideoPreviewView(videoURLString: urlString)
.frame(height: 375)
.background(Color.red)
}
}
}
}
}
Start playing 3
Start playing 2
Start playing 1
Now only the visible ChildViews are called. If you scroll down you shoudl see your desired output in the console. This should be applicable for your use case.
All code was tested and run with Xcode 12.4 and iOS 14.4

Related

SwiftUI navigationBarTitle not resetting to .large returning from Toolbar set to .inline

There are a few posts regarding SwiftUI .inline not resetting to .largeTitle when navigation returns to the parent:
For example:
Navigation bar title stays inline in iOS 15
and
Navigationbar title is inline on pushed view, but was set to large
While earlier posts seem to suggest this has been corrected, I'm running into the same problem, even in iOS 16, but I'm not using a < Back button, instead I'm using "Cancel" (and not show, "Save") on my DestinationView. My goal is to mimic Apple's practice of showing a modal view when adding data, but a show-style push on the navigation stack when viewing and editing existing data (e.g. Contacts app, Reminders app, Calendar app). The brief code below illustrates the problem without adding extra code to handle data updating (e.g. #EnviornmentObject).
When I run this in the Live Preview in Xcode 14.0.1, scheme set to iPhone 13 Pro, no problems. Click a NavLink, return from destination, and ContentView shows .large navigationBarTitle. BUT when I run in the simulator or on a 13 Pro device, returning to Home from a NavigationLink remains .inline unless I pull down on the list. If I switch to iPhone 14 Pro, the live preview looks fine, but the simulator shows a short of abrupt switch from inline back to large, not a smooth animation. Am I doing something wrong in the setup here or is there a bug in the implementation, noting that the behavior oddly holds to .inline on return home to ContentView, if I use this in either a simulator or device for iPhone 13 Pro. Thanks for guidance & insight!
struct ContentView: View {
#State private var sheetIsPresented = false
var items = ["Item1", "Item2", "Item3"]
var body: some View {
NavigationStack {
List {
ForEach(items, id: \.self) { item in
NavigationLink(item, destination: DestinationView(item: item))
.padding()
}
}
.navigationBarTitle("Home", displayMode: .large)
.listStyle(.plain)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
sheetIsPresented.toggle()
} label: {
Image(systemName: "plus")
}
}
}
}
.sheet(isPresented: $sheetIsPresented) {
NavigationStack {
DestinationView(item: "New!")
}
}
}
}
struct DestinationView: View {
var item: String
#Environment(.dismiss) private var dismiss
var body: some View {
List {
Text(item)
}
.toolbar {
ToolbarItem (placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
.listStyle(.plain)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden()
}
}

GeometryReader acting weird when presenting a modal on iOS 16. Bug or new behavior?

I'm seeing a weird behavior that is affecting one of my views in SwiftUI after upgrading to iOS 16.
Just to give some context, here is the stack:
Xcode 14
Simulator or real device on iOS 15.5 and 16
Considering the minimum reproducible code below:
struct ContentView: View {
#State private var isPresented: Bool = false
var body: some View {
GeometryReader { reader in
VStack(spacing: 36) {
Text("Screen frame:\n\(String(describing: reader.frame(in: .global)))")
.multilineTextAlignment(.center)
Button {
isPresented.toggle()
} label: {
Text("Open modal")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.onReceive(NotificationCenter.default.appDidBecomeActive()) { _ in
print(reader.frame(in: .global))
}
.onReceive(NotificationCenter.default.appDidEnterBackground()) { _ in
print(reader.frame(in: .global))
}
}
.sheet(isPresented: $isPresented) {
modalView
}
}
private var modalView: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
.padding()
}
}
extension NotificationCenter {
func appDidBecomeActive() -> AnyPublisher<Notification, Never> {
publisher(for: UIApplication.didBecomeActiveNotification).eraseToAnyPublisher()
}
func appDidEnterBackground() -> AnyPublisher<Notification, Never> {
publisher(for: UIApplication.didEnterBackgroundNotification).eraseToAnyPublisher()
}
}
As soon the view starts, it's possible to see the frame available due to the GeometryReader. Then following the steps:
Open the modal view
Send the app to the background
Open the app again
Close the modal
It's possible to see that the frame changed, and the values match with the 3D effect when a view is presenting another view, and it's never changing again to the right values unless you send the app again to the background or switch views (e.g. using a TabView).
I don't find anything on iOS release notes talking about it, so I supposed it must be a bug (I've filled out a bug report already).
On iOS 15, the frame value keeps stable at the same value.
I have a couple of views relying on the value of a GeometryReader, and it's causing my view to deform because of this issue. Does anyone know a way to force the recalculation for the GeometryReader for this case?
Any help is appreciated.
The issue won't occur if you control the display of the sheet with the new presentationDetents method, provided you do not request to cover the entire screen.
I modified your code as follows:
.sheet(isPresented: $isPresented) {
if #available(iOS 16, *) {
modalView
.presentationDetents([.fraction(0.99)])
}
else {
modalView
}
}
The issue will remain if you request .fraction(1), i.e. covering the whole screen.

LazyVGrid onTapGesture navigate to next screen swiftUI

I am quite new to swiftUI. I have created a grid view on tapping on which I want to go to next screen. But somehow I am not able to manage to push to next screen. I am doing like this:
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: gridItems, spacing: 16) {
ForEach(viewModel.pokemon) { pokemon in
PokemonCell(pokemon: pokemon, viewModel: viewModel)
.onTapGesture {
NavigationLink(destination: PokemonDetailView(pokemon: pokemon)) {
Text(pokemon.name)
}
}
}
}
}
.navigationTitle("Pokedex")
}
}
Upon doing like this, I am getting a warning stating
Result of 'NavigationLink<Label, Destination>' initializer is unused
Can someone please guide me, how to do this?
.onTapGesture adds an action to perform when the view recognizes a tap gesture. In your case you don't need to use .onTapGesture. If you want to go to another view when cell is tapped you need to write NavigationLink as below.
NavigationLink(destination: PokemonDetailView(pokemon: pokemon)) {
PokemonCell(pokemon: pokemon, viewModel: viewModel)
}
If you want to use .onTapGesture, another approach is creating #State for your tapped cell's pokemon and using NavigationLink's isActive binding. So when user tap the cell it will change the #State and toggle the isActive in .onTapGesture. You may need to add another Stack (ZStack etc.) for this.
NavigationView {
ZStack {
NavigationLink("", destination: PokemonDetailView(pokemon: pokemon), isActive: $isNavigationActive).hidden()
ScrollView {
// ...

How to disable / modify a button using SwiftUI?

I want to create a POC using SwiftUI and CoreML. I use a simple button to call some function (called test here). This function is pretty heavy as it performs a CoreML inference, and it can take up to a minute to compute.
I have several problems:
The button is still active even when the computation is ongoing. In other words, if I click the button several times before the processing of the first click is finished, the processing will be performed several times. I want to disable the button as long as the processing is ongoing.
I tried to modify the button's appearance to signify the user that the processing is ongoing. In the example bellow, I change the button color to red before calling the test function, and I change it back to blue when the processing is over. But it doesn't work.
In the code bellow, the test function is just sleeping for 5 seconds to simulate the CoreML inference.
func test() -> Void {
print("Start processing")
sleep(5)
print("End processing")
}
struct ContentView: View {
#State private var buttonColor : Color = .blue
var body: some View {
VStack {
Button(action: {
self.buttonColor = .red
test()
self.buttonColor = .blue
}) {
Text("Start")
.font(.title)
.padding(.horizontal, 40)
.padding(.vertical, 5)
.background(self.buttonColor)
.foregroundColor(.white)
}
}
}
}
I guess this problem is very straight forward for most of you. I just can't find the correct search keywords to solve it by myself. Thanks for your help.
Here is possible approach (see also comments in code). Tested & works with Xcode 11.2 / iOS 13.2.
struct ContentView: View {
#State private var buttonColor : Color = .blue
var body: some View {
VStack {
Button(action: {
self.buttonColor = .red
DispatchQueue.global(qos: .background).async { // do in background
test()
DispatchQueue.main.async {
self.buttonColor = .blue // work with UI only in main
}
}
}) {
Text("Start")
.font(.title)
.padding(.horizontal, 40)
.padding(.vertical, 5)
.background(self.buttonColor)
.foregroundColor(.white)
}
.disabled(self.buttonColor == .red) // disabled while calculating
}
}
}

How to create slot-machine metaphor in SwiftUI Picker?

Is it possible to create a slot machine with swiftUI to show two sets of values?
In UIKit, UIPickerView provides the option to have multiple components in your picker view. SwiftUI's Picker does not. However, you can use more than one Picker in an HStack instead. The perspective may look slightly different than a UIPickerView with multiple components in some instances, but to me it looks perfectly acceptable.
Here's an example of a slot machine with 4 pickers side by side and a button that "spins" the slot-machine when tapped (note that I disabled user interaction on the pickers so they can only be spun using the button):
enum Suit: String {
case heart, club, spade, diamond
var displayImage: Image {
return Image(systemName: "suit.\(self.rawValue).fill")
}
}
struct ContentView: View {
#State private var suits: [Suit] = [.heart, .club, .spade, .diamond]
#State private var selectedSuits: [Suit] = [.heart, .heart, .heart, .heart]
var body: some View {
VStack {
HStack(spacing: 0) {
ForEach(0..<self.selectedSuits.count, id: \.self) { index in
Picker("Suits", selection: self.$selectedSuits[index]) {
ForEach(self.suits, id: \.self) { suit in
suit.displayImage
}
}
.frame(minWidth: 0, maxWidth: .infinity)
.clipped()
.disabled(true)
}
}
Button(action: self.spin) {
Text("Spin")
}
}
}
private func spin() {
self.selectedSuits = self.selectedSuits.map { _ in
self.suits.randomElement()!
}
}
}
This is just an example, and could no doubt be improved, but it's a decent starting point.
Keep in mind that this code does throw a warning in Xcode Beta 5 -
'subscript(_:)' is deprecated: See Release Notes for migration path.
I haven't had a chance to look into this, but the example still works and should help you with what you're trying to achieve.