Is there a way to call a function when a SwiftUI Picker selection changes? - swift

I would like to call a function when selectedOption's value changes. Is there a way to do this in SwiftUI similar to when editing a TextField?
Specifically, I would like to save the selected option when the user changes the selectedOption.
Here is my picker:
struct BuilderPicker: View {
let name: String
let options: Array<String>
#State var selectedOption = 0
var body: some View {
HStack {
Text(name)
.font(.body)
.padding(.leading, 10)
Picker(selection: $selectedOption, label: Text(name)) {
ForEach(0 ..< options.count) {
Text(self.options[$0]).tag($0)
}
}.pickerStyle(SegmentedPickerStyle())
.padding(.trailing, 25)
}.onTapGesture {
self.selectedOption = self.selectedOption == 0 ? 1 : 0
}
.padding(.init(top: 10, leading: 10, bottom: 10, trailing: 0))
.border(Color.secondary, width: 3)
.padding(.init(top: 0, leading: 15, bottom: 0, trailing: 15))
.font(.body)
}
}
I’m still new to SwiftUI and would love some help. Thanks!

If the #State value will be used in a View, you don't need extra variable name
struct BuilderPicker: View {
// let name: String = ""
let options: Array<String> = ["1", "2","3","4","5"]
#State var selectedOption = 0
var body: some View {
HStack {
Text(options[selectedOption])
.font(.body)
.padding(.leading, 10)
Picker(selection: $selectedOption, label: Text(options[selectedOption])) {
ForEach(0 ..< options.count) {
Text(self.options[$0]).tag($0)
}
}.pickerStyle(SegmentedPickerStyle())
.padding(.trailing, 25)}
// }.onTapGesture {
// self.selectedOption = self.selectedOption == 0 ? 1 : 0
// }
.padding(.init(top: 10, leading: 10, bottom: 10, trailing: 0))
.border(Color.secondary, width: 3)
.padding(.init(top: 0, leading: 15, bottom: 0, trailing: 15))
.font(.body)
}
}
If you need separated operation on the #State, the simplest way is adding one line : onReceive() to the view.
HStack {
Text("")
.font(.body)
.padding(.leading, 10)
Picker(selection: $selectedOption, label: Text("")) {
ForEach(0 ..< options.count) {
Text(self.options[$0]).tag($0)
}
}.pickerStyle(SegmentedPickerStyle())
.padding(.trailing, 25)}
// }.onTapGesture {
// self.selectedOption = self.selectedOption == 0 ? 1 : 0
// }
.padding(.init(top: 10, leading: 10, bottom: 10, trailing: 0))
.border(Color.secondary, width: 3)
.padding(.init(top: 0, leading: 15, bottom: 0, trailing: 15))
.font(.body)
.onReceive([self.selectedOption].publisher.first()) { (value) in
print(value)
}

The previous solution will end up in an infinite loop if you update an ObservedObject in the callback since .onReceive is also called when the View got rendered.
→ A better approach is to use a .onChange method on the Binding itself:
Picker(selection: $selectedOption.onChange(doSomething), label: Text("Hello world")) {
// ...
}
To do so you need to write an extension for Binding like described here.

Related

SwiftUI-How to transition between text when a button is pressed

I am making an exercise app and I want to transition between different exercises without making many seperate views as I am also making a timer which will publish too many times if I did that. I want to make it so that when I click a button, it will transition all the text from exercise1 to exercise 2 then to exercise 3 and so on. I want to transition all these using the same button.
So far what I have done is created navigation paths to different views that contain the different exercises but the timer broke when I did that because I called it many times causing it to countdown too fast. I want to see if I can contain all the exercises inside 1 view and transition between them. So for example I want to transition from:
Text(exercisePlan.exercise.title) to Text(exercisePlan.exercise2.title) then to Text(exercisePlan.exercise3.title) all with a click of one button
import SwiftUI
import AVKit
struct ExerciseScreenView: View {
#Binding var streaks: Streaks
#Binding var timerStruct: TimerStruct
var countdownTimer = 300
#State var player = AVPlayer()
var exercisePlan: ExercisePlan
#Binding var navigationPath: NavigationPath
func reset() {
timerStruct.countdownTimer = 300
timerStruct.timerRunning = false
}
var body: some View {
VStack {
Form {
Section(header: Text("1st Exercise (Scroll down for exercise steps)")) {
Text(exercisePlan.exercise.title)
.font(.system(size: 35, weight: .medium))
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
Section(header: Text("Video example")) {
VideoPlayer(player: exercisePlan.exercise.video)
.scaledToFit()
.frame(alignment: .center)
.cornerRadius(10)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
Section(header: Text("Steps:")){
Text(exercisePlan.exercise.steps)
.font(.system(size: 20, weight: .regular))
.padding()
.frame(alignment: .center)
}
}
TimerView(streaks: $streaks, timerStruct: $timerStruct)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 15, trailing: 0))
Button {
navigationPath.append("ExerciseScreen2View")
} label: {
Text("Next exercise")
.padding()
.background((Color(red: 184/255, green: 243/255, blue: 255/255)))
.foregroundColor(.black)
.cornerRadius(10)
.navigationBarBackButtonHidden()
}
.padding(EdgeInsets(top: 0, leading: 10, bottom: 10, trailing: 10))
}
}
}
What you want to do is create an array of the Exercises. Pressing the button should increase an index to get the relevant plan to show in your view.
Small code example:
struct ExerciseScreenView: View {
var exercises: [Exercise]
#State var index: 0
var body: some View {
VStack {
Text(exercises[index].title)
Button {
if exercisePlan.count - 1 > index {
index += 1
} else {
// leave screen
}
} label: {
Text("Next exercise")
}
}
}
}
}
This is a very basic example and can be improved by a lot, but it gives you a basic idea how to do it.

Having issue with DispatchQueue not working after 3-4 executions in Swift

I have the following code below to show a Message to the user and then disappear. Everything works fine for the first 1 to 3 tries, but anything above that the Message window does not disappear as it did on the previous attempts/required.
struct ContentView: View {
#State var showingNotice = false
var body: some View {
ZStack {
Button(action: {
self.showingNotice = true
}, label: {
Text("Show Notice")
})
if showingNotice {
FloatingNotice(showingNotice: $showingNotice)
}
}
.animation(.easeInOut(duration: 1))
}
}
struct FloatingNotice: View {
#Binding var showingNotice: Bool
var body: some View {
VStack (alignment: .center, spacing: 8) {
Image(systemName: "checkmark")
.foregroundColor(.white)
.font(.system(size: 48, weight: .regular))
.padding(EdgeInsets(top: 20, leading: 5, bottom: 5, trailing: 5))
Text("Review added")
.foregroundColor(.white)
.font(.callout)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
}
.background(Color.snackbar.opacity(0.75))
.cornerRadius(5)
.transition(.scale)
.onAppear(perform: {
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
self.showingNotice = false
})
})
}
}
Can anyone see what I am missing or help figure why it "stops" working after multiple executions?
Sometimes onAppear will not called so your self.showingNotice = false is not getting called. So you can do one thing move your delay block inside the button click it self as below.
struct ContentView: View {
#State var showingNotice = false
var body: some View {
ZStack {
Button(action: {
self.showingNotice = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
self.showingNotice = false
})
}, label: {
Text("Show Notice")
})
if showingNotice {
FloatingNotice(showingNotice: $showingNotice)
}
}
.animation(.easeInOut(duration: 1))
}
}
struct FloatingNotice: View {
#Binding var showingNotice: Bool
var body: some View {
VStack (alignment: .center, spacing: 8) {
Image(systemName: "checkmark")
.foregroundColor(.white)
.font(.system(size: 48, weight: .regular))
.padding(EdgeInsets(top: 20, leading: 5, bottom: 5, trailing: 5))
Text("Review added")
.foregroundColor(.white)
.font(.callout)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
}
.background(Color(.red))
.cornerRadius(5)
.transition(.scale)
}
}

SwiftUI keyboard navigation in lists on MacOS

I'm trying to implement a list which can be navigated with arrow keys - up/down. I've created layout, but now I don't totally understand how(and where) to make up/down keys intercepted so I could add my custom logic. I already tried onMoveCommand with focusable but that did not work(wasn't firing at all)
Code I have - below
public var body: some View {
VStack(spacing: 0.0) {
VStack {
HStack(alignment: .center, spacing: 0) {
Image(systemName: "command")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.padding(.leading, 20)
.offset(x: 0, y: 1)
TextField("Search Commands", text: $state.commandQuery)
.font(.system(size: 20, weight: .light, design: .default))
.textFieldStyle(.plain)
.onReceive(
state.$commandQuery
.debounce(for: .seconds(0.1), scheduler: DispatchQueue.main)
) { val in
state.fetchMatchingCommands(val: val)
}
.padding(16)
.foregroundColor(Color(.systemGray).opacity(0.85))
.background(EffectView(.sidebar, blendingMode: .behindWindow))
}
}
Divider()
VStack(spacing: 0) {
List(state.filteredCommands.isEmpty && state.commandQuery.isEmpty ?
commandManager.commands : state.filteredCommands, selection: $selectedItem) { command in
VStack(alignment: .leading, spacing: 0) {
Text(command.title).foregroundColor(Color.white)
.padding(EdgeInsets.init(top: 0, leading: 10, bottom: 0, trailing: 0))
.frame(height: 10)
}.frame(maxWidth: .infinity, maxHeight: 15, alignment: .leading)
.listRowBackground(self.selectedItem == command ?
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(Color(.systemBlue)) :
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(Color.clear) )
.onTapGesture {
self.selectedItem = command
callHandler(command: command)
}.onHover(perform: { _ in self.selectedItem = command })
}.listStyle(SidebarListStyle())
}
}
.background(EffectView(.sidebar, blendingMode: .behindWindow))
.foregroundColor(.gray)
.edgesIgnoringSafeArea(.vertical)
.frame(minWidth: 600,
minHeight: self.state.isShowingCommandsList ? 400 : 28,
maxHeight: self.state.isShowingCommandsList ? .infinity : 28)
}
This is how it looks - and I want to make focus move between found list items
If I understand your question correctly, you want to use the arrow keys to "move" from the search TextField, to the list of items, and then navigate the list with the up/down arrow keys.
Try something simple like this example code, to monitor the up/down arrow keys, and take the appropriate action.
Adjust/tweak the logic to suit your needs.
import Foundation
import SwiftUI
import AppKit
struct ContentView: View {
let fruits = ["apples", "pears", "bananas", "apricot", "oranges"]
#State var selection: Int?
#State var search = ""
var body: some View {
VStack {
VStack {
HStack(alignment: .center, spacing: 0) {
Image(systemName: "command")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.padding(.leading, 20)
.offset(x: 0, y: 1)
TextField("Search", text: $search)
.font(.system(size: 20, weight: .light, design: .default))
.textFieldStyle(.plain)
}
}
Divider()
List(selection: $selection) {
ForEach(fruits.indices, id: \.self) { index in
Text(fruits[index]).tag(index)
}
}
.listStyle(.bordered(alternatesRowBackgrounds: true))
}
.onAppear {
NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { nsevent in
if selection != nil {
if nsevent.keyCode == 125 { // arrow down
selection = selection! < fruits.count ? selection! + 1 : 0
} else {
if nsevent.keyCode == 126 { // arrow up
selection = selection! > 1 ? selection! - 1 : 0
}
}
} else {
selection = 0
}
return nsevent
}
}
}
}

How to align with a certain view in a VStack?

I'm trying to achieve the following layout:
As you can see, Foo is aligned with Bar horizontally, and there's a vertical line at the bottom of Foo.
I haven't managed to do it, the best I can do is the following one:
Here is the corresponding code:
import SwiftUI
struct ContentView: View {
var body: some View {
HStack {
VStack(spacing: 5) {
Text("Foo")
.padding(.bottom, 0)
getVerticalLine()
}
Text("Bar")
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.yellow)
}
}
private func getVerticalLine() -> some View {
return Color.gray
.frame(width: 1, height: 30)
.padding(.leading, 0)
}
}
My question is: how to modify the code to achieve the expected layout?
P.S. In the end, I want to achieve something like this:
Here is the complete code that you want. You need to use multiplier for constant so that it fits in all devices.
CententView
struct ContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
FooBarView(isLastOne: false)
.padding(EdgeInsets(top: 0, leading: 10, bottom: -18, trailing: 0))
FooBarView(isLastOne: false)
.padding(EdgeInsets(top: 0, leading: 10, bottom: -18, trailing: 0))
FooBarView(isLastOne: false)
.padding(EdgeInsets(top: 0, leading: 10, bottom: -18, trailing: 0))
FooBarView(isLastOne: true)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 0))
}
}
}
FooBarView
struct FooBarView: View {
var isLastOne: Bool
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
VStack(spacing: 5) {
Text("Foo")
.padding(.bottom, 0)
}
Text("Bar")
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.yellow)
}
if !isLastOne {
getVerticalLine()
.padding(EdgeInsets(top: -5, leading: 12, bottom: 0, trailing: 0))
}
}
}
private func getVerticalLine() -> some View {
return Color.gray
.frame(width: 1, height: 40)
.padding(.leading, 0)
}
}
Each of your activities are a single HStack, with the time on the left. The line is just something that connects each row, not necessarily part of the row itself.
You can use a ZStack to show the gray line on the back and the rows on the front; a background on the first column will avoid overlapping the gray line.
The alignment is assured by giving the same frame width to the first column (time) and the vertical line.
Here's an example:
struct Example: View {
struct Activity: Hashable, Identifiable {
let id = UUID()
let time: String
let activity: String
}
let activities = [Activity(time: "10.00", activity: "Exercise"),
Activity(time: "11.00", activity: "Work"),
Activity(time: "12.00", activity: "Study"),
Activity(time: "13.00", activity: "Coding")]
let firstColumnWidth = 80.0
let spacingBetweenLines = 30.0
let internalPadding = 10.0
let lineHeight = 20.0
var body: some View {
ZStack {
HStack {
getVerticalLine
.frame(width: firstColumnWidth)
Spacer()
}
VStack(spacing: spacingBetweenLines) {
ForEach(activities) { item in
HStack(spacing: 5) {
Text(item.time)
.frame(height: lineHeight)
.padding(internalPadding)
.frame(width: firstColumnWidth)
.background(.white)
Text(item.activity)
.frame(height: lineHeight)
.padding(internalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.yellow)
}
}
}
}
.padding()
}
private var verticalLine: some View {
let lineHeight = internalPadding * 2 + lineHeight + spacingBetweenLines
return Color.gray
.frame(width: 1, height: lineHeight * Double(activities.count - 1))
.background(.green)
}
}

SwiftUI - Remove space between cells

I am implementing a list view in SwiftUI. What I am trying to approach is to have cells that has no space between any other cells or parent view.
So in this screenshot as you can see there is a space between every cell and also space with the edge of the phone, which I want to remove.
struct FlickrView : View {
var flickrResponse: [FlickrResponse]
var body: some View {
List(flickrResponse) { item in
FlickrImageCell(response: item)
}
}
}
struct FlickrImageCell : View {
var response: FlickrResponse
var body: some View {
return ZStack(alignment: .topLeading) {
Image(uiImage: response.image ?? UIImage())
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: nil, height: 100.0, alignment: .center)
.clipShape(Rectangle())
.padding(0)
Text(response.title).fontWeight(.medium).multilineTextAlignment(.center)
}
}
}
I have tried this modifier:
.padding(EdgeInsets(top: 0, leading: -20, bottom: 20, trailing: -20))
But I have two problems with this approach: First, I don't think its convenient to write literal negative values. Second, the bottom padding does not work with any value.
So any suggestions?
I've had good luck with listRowInsets
struct ContentView: View {
var body: some View {
List {
Color.red
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
Color.blue
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
Color.yellow
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}
}