How can I use TimelineView in Swift 5 to refresh a text every second? - swift

I'm pretty new at swift coding and I'm trying to do a very simple project: just a clock that shows the time. I'm using TimelineView to refresh the time every second, but it's not working. This is my code:
import SwiftUI
struct ContentView: View {
#State var hour: Int = Calendar.current.component(.hour, from: Date())
#State var minute: Int = Calendar.current.component(.minute, from: Date())
#State var second: Int = Calendar.current.component(.second, from: Date())
var body: some View {
ZStack {
VStack{
Spacer()
HStack {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(String(hour))
Text(String(minute))
Text(String(second))
}
}
Spacer()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
.previewInterfaceOrientation(.portrait)
}
}
}
Since my hour, minute and second variables are #State and I'm using the TimelineView, they should refresh every second, shouldn't they?
I'm very confused and I would appreciate some help. Thank you very much.

You have to observe changes in the timeline.
Here I used onChange and update the value of min, sec, and hour.
struct TimerView: View {
var date: Date
#State var hour: Int = Calendar.current.component(.hour, from: Date())
#State var minute: Int = Calendar.current.component(.minute, from: Date())
#State var second: Int = Calendar.current.component(.second, from: Date())
var body: some View {
VStack {
Text(String(hour))
Text(String(minute))
Text(String(second))
}
.onChange(of: date) { _ in
second += 1
if second > 59 {
minute += 1
second = 0
if minute > 59 {
hour += 1
minute = 0
if hour > 23 {
hour = 0
}
}
}
}
}
}
struct ContentView: View { var body: some View {
ZStack {
VStack{
Spacer()
HStack {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
TimerView(date: timeline.date)
}
}
Spacer()
}
}
}
}

As the documentation says (https://developer.apple.com/documentation/swiftui/timelineview):
A timeline view acts as a container with no appearance of its own. Instead, it redraws the content it contains at scheduled points in time
The content it contains is defined in the closure you provide:
TimelineView(...) { timeline in
// content which gets redrawn
}
Inside this closure you have access to a TimelineView.Context (https://developer.apple.com/documentation/swiftui/timelineview/context). With the help of this context, you can access the date which triggered the update / redraw like so:
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text("\(timeline.date)")
}
This will produce the following output:
To improve formatting, you could use a DateFormatter (https://developer.apple.com/documentation/foundation/dateformatter):
struct ContentView: View {
private let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .medium
return dateFormatter
}()
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text("\(dateFormatter.string(from: timeline.date))")
}
}
}

Just make your hour-minute-second as computed property not #State
struct ContentView: View {
var hour: Int {
Calendar.current.component(.hour, from: Date())
}
var minute: Int {
Calendar.current.component(.minute, from: Date())
}
var second: Int {
Calendar.current.component(.second, from: Date())
}
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
HStack {
Text(String(hour))
Text(String(minute))
Text(String(second))
}
}
}
}

Related

How to make the date change simultaneously in all views?

Date not change simultaneously in all views.
I want to link two calendars. Standard and custom. But they don't connect.
When I change the date in one, it doesn't change in the other.
I made Published:
import Combine
import Foundation
class CustomCalendar: ObservableObject {
#Published var currentDate = Date()
var currentThreeWeek: [Date] = []
init() {
fetchCurrentThreeWeek()
}
func fetchCurrentThreeWeek() {
let calendar = Calendar.current
var todayDay = DateInterval(start: Date(), duration: 1814400).start
let lastDay = DateInterval(start: Date(), duration: 1814400).end
currentThreeWeek.append(todayDay)
while todayDay < lastDay {
todayDay = calendar.date(byAdding: .day, value: 1, to: todayDay)!
currentThreeWeek.append(todayDay)
}
}
func extractDate(date: Date, format: String) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = format
dateFormatter.locale = Locale(identifier: "rus")
return dateFormatter.string(from: date)
}
func isToday(date: Date) -> Bool {
let calendar = Calendar.current
return calendar.isDate(currentDate, inSameDayAs: date)
}
}
When I select a date it doesn't change in other views.
import SwiftUI
struct FilterView: View {
#StateObject private var calendar = CustomCalendar()
#Binding var filterViewIsPresented: Bool
let todayDay = DateInterval(start: Date(), duration: 1814400).start
let lastDay = DateInterval(start: Date(), duration: 1814400).end
var body: some View {
VStack {
DatePicker("", selection: $calendar.currentDate, in: todayDay...lastDay, displayedComponents: .date)
.labelsHidden()
.environment(\.locale, Locale.init(identifier: "ru"))
HorizontalCalendarView()
HorizontalCalendarView()
}
}
}
struct FilterView_Previews: PreviewProvider {
static var previews: some View {
FilterView(filterViewIsPresented: .constant(false))
}
}
Custom calendar. On tap Gesture I change currentDate
import SwiftUI
struct HorizontalCalendarView: View {
#StateObject private var calendar = CustomCalendar()
var body: some View {
ScrollViewReader { value in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(calendar.currentThreeWeek, id: \.self) { day in
VStack(spacing: 0) {
Text(calendar.extractDate(date: day, format: "dd"))
.font(.title3)
.fontWeight(.bold)
Text(calendar.extractDate(date: day, format: "EEE"))
RoundedRectangle(cornerRadius: 10, style: .continuous)
.frame(width: calendar.isToday(date: day) ? 40 : 0, height: 5)
.opacity(calendar.isToday(date: day) ? 1 : 0)
.padding(4)
}
.frame(width: 45, height: 45)
.foregroundStyle(calendar.isToday(date: day) ? .primary : .secondary )
.foregroundColor(calendar.isToday(date: day) ? .white : .black)
.padding(8)
.background(
ZStack {
if calendar.isToday(date: day) {
RoundedRectangle(cornerRadius: 10, style: .continuous)
}
}
)
.onTapGesture {
withAnimation(.easeIn(duration: 0.2)) {
calendar.currentDate = day
value.scrollTo(calendar.currentDate, anchor: .leading)
}
}
}
.padding(9)
}
}
Text(calendar.currentDate.formatted())
}
}
}
struct HorizontalCalendarView_Previews: PreviewProvider {
static var previews: some View {
HorizontalCalendarView()
}
}
How can I do this?
You have two options:
Pass calendar in HorizontalCalendarView constructor and use #ObservedObject property wrapper instead of #StateObject:
struct HorizontalCalendarView: View {
#ObservedObject private var calendar: CustomCalendar
init(_ calendar: CustomCalendar) {
self.calendar = calendar
}
...
and just pass it in FilterView
HorizontalCalendarView(calendar)
Another option is to use #EnvironmentObject (it's preferred method for deeply nested views in your example option 1 is better):
struct HorizontalCalendarView: View {
#EnvironmentObject private var calendar: CustomCalendar = CustomCalendar()
...
then you have to pass calendar with environmentObject modifier:
HorizontalCalendarView().environmentObject(calendar)
Note: in order to #EnvironmentObject works as expected it is not necessary to use environmentObject modifier on actual view, you can use this modifier in any of parent views. That makes it perfect for deep nested views

Display function output live without Button press

My Swift UI code currently calls a function to display calculations upon a button call. I'd like to display the function's output without the button call (in other words, the function is "live" and constantly calculating anytime a necessary variable is changed). Basically, I'm looking to get rid of the button that triggers this function call calculation, and always have the function's display shown. It has default values so it should have info even before the user inputs or something is changed.
The first screenshot shows the code currently, and the second shows where I'd like the time calculation string to always be. Note: this uses a Create ML file, so if you're inputting this code into your editor, it's not necessary to have the model use to calculate. Any use and output of the variables will do and I've left some commented code that might help.
I'm thinking there might be a calculate on change of X, Y, Z variable needed here. I'm not sure the best way to approach this and would love any ideas. Thanks!
import CoreML
import SwiftUI
struct ContentView: View {
#State var wakeUpTime = defaultWakeTime
#State var coffeeAmount = 1.0
#State var sleepAmount = 8.0
#State var alertTitle = ""
#State var alertMessage = ""
#State var showAlert = false
static var defaultWakeTime: Date {
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? Date.now
}
var body: some View {
NavigationView {
Form {
Section {
DatePicker("Please enter a time", selection: $wakeUpTime, displayedComponents: .hourAndMinute)
.labelsHidden()
} header: {
Text("When do you want to wake up?")
.font(.headline)
}
VStack(alignment: .leading, spacing: 0) {
Text("Hours of sleep?")
.font(.headline)
Stepper(sleepAmount == 1 ? "1 hour" : "\(sleepAmount.formatted()) hours", value: $sleepAmount, in: 1...12, step: 0.25)
}
VStack(alignment: .leading, spacing: 0) {
Text("Cups of coffee?")
.font(.headline)
Stepper(coffeeAmount == 1 ? "1 cup" : "\(coffeeAmount.formatted()) cups", value: $coffeeAmount, in: 1...12, step: 0.25)
}
Section {
Text("Head to bed at: IDEAL TIME HERE")
}
}
.navigationTitle("BetterRest")
.toolbar {
Button("Calculate", action: calculateBedtime)
}
.alert(alertTitle, isPresented: $showAlert) {
Button("Ok") { }
} message: {
Text(alertMessage)
}
}
}
func calculateBedtime() {
do {
let config = MLModelConfiguration()
let model = try SleepCalculator(configuration: config)
let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUpTime)
let hour = (components.hour ?? 0) * 60 * 60
let minute = (components.minute ?? 0) * 60
let predicition = try model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
let sleepTime = wakeUpTime - predicition.actualSleep
alertTitle = "Your ideal bedtime is..."
alertMessage = sleepTime.formatted(date: .omitted, time: .shortened)
}
catch {
alertTitle = "Error"
alertMessage = "Sorry. There was a problem calculating your bedtime."
}
showAlert = true
// IF TRYING WITHOUT CREATE ML MODEL, comment out all of above^
// let alertTitle = "Showing calculated title"
// let alertMessage = "7:15 am"
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
you could try this approach, where you create a class BedTimeModel: ObservableObject to
monitor changes in the various variables that is used to calculate (dynamically)
your sleepTime using func calculateBedtime().
EDIT-1: using Optional sleepTime
class BedTimeModel: ObservableObject {
#Published var sleepTime: Date? = Date() // <-- here optional
#Published var wakeUpTime = defaultWakeTime {
didSet { calculateBedtime() }
}
#Published var coffeeAmount = 1.0 {
didSet { calculateBedtime() }
}
#Published var sleepAmount = 8.0 {
didSet { calculateBedtime() }
}
// can also change this to return the calculated value and use it to update the `sleepTime`
func calculateBedtime() {
// do {
// let config = MLModelConfiguration()
// let model = try SleepCalculator(configuration: config)
// let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUpTime)
// let hour = (components.hour ?? 0) * 60 * 60
// let minute = (components.minute ?? 0) * 60
// let predicition = try model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
//
// sleepTime = wakeUpTime - predicition.actualSleep // <-- here
// }
// catch {
// sleepTime = nil // <-- here could not be calculated
// }
// for testing, adjust the real calculation to update sleepTime
sleepTime = wakeUpTime.addingTimeInterval(36000 * (sleepAmount + coffeeAmount))
}
static var defaultWakeTime: Date {
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? Date.now
}
}
struct ContentView: View {
#StateObject private var vm = BedTimeModel() // <-- here
var body: some View {
NavigationView {
Form {
Section {
DatePicker("Please enter a time", selection: $vm.wakeUpTime, displayedComponents: .hourAndMinute)
.labelsHidden()
} header: {
Text("When do you want to wake up?").font(.headline)
}
VStack(alignment: .leading, spacing: 0) {
Text("Hours of sleep?").font(.headline)
Stepper(vm.sleepAmount == 1 ? "1 hour" : "\(vm.sleepAmount.formatted()) hours", value: $vm.sleepAmount, in: 1...12, step: 0.25)
}
VStack(alignment: .leading, spacing: 0) {
Text("Cups of coffee?").font(.headline)
Stepper(vm.coffeeAmount == 1 ? "1 cup" : "\(vm.coffeeAmount.formatted()) cups", value: $vm.coffeeAmount, in: 1...12, step: 0.25)
}
Section {
// -- here
if let stime = vm.sleepTime {
Text("Head to bed at: \(stime.formatted(date: .omitted, time: .shortened))")
} else {
Text("There was a problem calculating your bedtime.")
}
}
}
.navigationTitle("BetterRest")
}
}
}

Why doesn't code in a body property of a View run each time an #State variable of its parent View changes?

I wish to run the function calculateBedtime() when the app first loads, and each time any of the #State variables of ContentView change, so that an updated bedtime is displayed constantly at the bottom of the app in the lowermost Section. However, the app acts as if variable bedtime just keeps its initial value all the time and never changes.
What I am expecting to happen is that when I change any #State variable, say using the DatePicker to change wakeUp, the body property is reinvoked, the first line of which is a call to calculateBedtime(), and so this function runs and updates bedtime as frequently as I want it to.
import SwiftUI
struct ContentView: View {
#State private var wakeUp = defaultWakeTime
#State private var bedtime = ""
#State private var sleepAmount = 8.0
#State private var coffeeAmount = 1
#State private var alertTitle = ""
#State private var alertMessage = ""
#State private var showingAlert = false
var body: some View {
bedtime = calculateBedtime()
return NavigationView
{
Form
{
Section(header: Text("When do you want to wake up?").font(.headline))
{
Text("When do you want to wake up?")
.font(.headline)
DatePicker("Please enter a time", selection: $wakeUp, displayedComponents: .hourAndMinute)
.labelsHidden()
.datePickerStyle(WheelDatePickerStyle())
}
Section(header: Text("Desired amount of sleep")
.font(.headline))
{
Stepper(value: $sleepAmount, in: 4...12, step: 0.25)
{
Text("\(sleepAmount, specifier: "%g") hours")
}
}
Section(header: Text("Daily coffee intake")
.font(.headline))
{
Picker("\(coffeeAmount+1) cup(s)", selection: $coffeeAmount)
{
ForEach(1..<21)
{ num in
if num==1
{
Text("\(num) cup")
}
else
{
Text("\(num) cups")
}
}
}
.pickerStyle(MenuPickerStyle())
}
Section(header: Text("Your Ideal Bedtime")
.font(.headline))
{
Text("\(bedtime)")
.font(.largeTitle)
}
}
.navigationBarTitle("BetterRest")
}
/*.onAppear(perform: {
calculateBedtime()
})
.onChange(of: wakeUp, perform: { value in
calculateBedtime()
})
.onChange(of: sleepAmount, perform: { value in
calculateBedtime()
})
.onChange(of: coffeeAmount, perform: { value in
calculateBedtime()
})*/
}
static var defaultWakeTime: Date
{
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? Date()
}
func calculateBedtime() -> String
{
let model = SleepCalculator()
let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUp)
let hour = (components.hour ?? 0) * 60 * 60
let minute = (components.minute ?? 0) * 60
var sleepTime = ContentView.defaultWakeTime
do
{
let prediction = try
model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
sleepTime = wakeUp - prediction.actualSleep
let formatter = DateFormatter()
formatter.timeStyle = .short
alertMessage = formatter.string(from: sleepTime)
alertTitle = "Your ideal bedtime is..."
} catch {
alertTitle = "Error"
alertMessage = "Sorry, there was a problem calculating your bedtime."
}
showingAlert = true
return alertMessage
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
What is the problem here? I am new to SwiftUI and feel that I must have a crucial misunderstanding of how the #State wrapper works. And what would be a good way to get the behavior I desire?
#State variables can only be mutated from within the body of your view and methods invoked by it; for anything else, you need to use ObservableObject which I think will solve your problem here.
You should only access a state property from inside the view’s body, or from methods called by it. For this reason, declare your state properties as private, to prevent clients of your view from accessing them. It is safe to mutate state properties from any thread.
More or less the scaffolding of the code below should achieve the results you want:
class SleepTimerViewModel: ObservableObject {
#Published public var bedTimeMessage: String?
}
struct ContentView: View {
#ObservedObject public var sleepTimerViewModel: SleepTimerViewModel
var body: some View {
Text(sleepTimerViewModel.bedTimeMessage)
}
public func updateBedTimeMessage() {
sleepTimerViewModel.bedTimeMessage = "Hello World"
}
}
I do think it's kind of annoying that Swift just don't care to let you know that you're updating a #State variable incorrectly. It just silently ignores the value you're trying to set, which is super annoying!

SwiftUI: how to update the range of a ForEach loop based on the value of a Picker

So I'm trying to have a ForEach loop update the amount of times a View is looped based on what Month (value in a picker) is selected. In my case, they will be looping based on the number of days in the month of the selected month for the given year. I already have a function that gives me the number of days in each month, however, when I plug that into the ForEach Loop, it only gets run based on the first month selected and stays iterating the number of days of that month for the rest. Here is my code for the ForEach Loop:
ForEach(0..<getRange(year: yearIndex, month: monthIndex + indexCheck)) { i in
NavigationLink(destination: ContentView(day: yearData[yearIndex].months[monthIndex].dayInfo[i])) {
DayRow(day: yearData[yearIndex].months[monthIndex].dayInfo[i])
}
}
and here is the getRange() Function:
func getRange(year: Int, month: Int) -> Int {
return Calendar.current.range(of: .day, in: .month, for: Calendar.current.date(from: DateComponents(year: year + 2020, month: month + 1))!)!.count
}
The yearIndex variable is linked to the picker value of three years, (2020, 2021, 2022). Here is the code for it:
Picker("Years", selection: $yearIndex) {
ForEach(0 ..< year.count) { i in
Text(String(self.year[i])).tag(i)
}
}
.pickerStyle(SegmentedPickerStyle())
The monthIndex variable is linked to the picker with the months in the year (Jan-Dec). Here is the code for it:
Picker("Month", selection: $monthIndex) {
ForEach(0 ..< monthArray.count) { i in
Text(self.monthArray[i]).tag(i)
}
}
.padding(.bottom, 2)
I'm not sure what I'm doing wrong, and I'm not sure how to do this, so any help would be greatly appreciated! I'm still quite new to Swift/SwiftUI, so any advice to better code this would also be appreciated!
EDIT: Here is a minimal reproducible example as requested:
struct ContentView: View {
#State var year = [2020, 2021, 2022]
//monthSymbols gets an array of all the months
#State var monthArray = DateFormatter().monthSymbols!
#State var yearIndex = 0
#State var monthIndex = 0
#State var indexCheck = 0
#State var indexTest = 0
var body: some View {
NavigationView {
List {
Section {
VStack {
Picker("Years", selection: $yearIndex) {
ForEach(0 ..< year.count) { i in
Text(String(self.year[i])).tag(i)
}
}
.pickerStyle(SegmentedPickerStyle())
Divider()
Picker("Month", selection: $monthIndex) {
ForEach(0 ..< monthArray.count) { i in
Text(self.monthArray[i]).tag(i)
}
}
.padding(.bottom, 2)
}
}
Section(header: Text("What I love about you")) {
ForEach(0..<getRange(year: yearIndex, month: monthIndex + indexCheck)) { i in
NavigationLink(destination: DetailsView()) {
Text("Row \(i)")
}
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("\(monthArray[monthIndex + indexCheck]) \(String(year[yearIndex]))"))
}
}
func getRange(year: Int, month: Int) -> Int {
return Calendar.current.range(of: .day, in: .month, for: Calendar.current.date(from: DateComponents(year: year + 2020, month: month + 1))!)!.count
}
}
struct YearView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I cleaned a little bit your code so it's more readable.
Here is the ContentView:
struct ContentView: View {
let yearArray = [2020, 2021, 2022]
let monthArray = DateFormatter().monthSymbols!
// you don't need to operate on indices, you can use real values
#State var selectedYear = 2020
#State var selectedMonth = 1
// improved readability
var combinedYearMonth: String {
"\(monthArray[selectedMonth - 1]) \(selectedYear)"
}
var body: some View {
NavigationView {
List {
pickerSection
daySelectionSection
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(combinedYearMonth)
}
}
}
The part responsible for displaying list sections:
// sections extracted to a private extension (you can still have everything in one `ContentView` struct if you want)
private extension ContentView {
var pickerSection: some View {
Section {
yearPicker
monthPicker
}
}
var daySelectionSection: some View {
Section(header: Text("What I love about you")) {
ForEach(dayRange, id: \.self) { day in
NavigationLink(destination: DetailsView()) {
Text("Day \(day)")
}
}
}
}
// create a range of days in the `selectedMonth` for the `selectedYear`
var dayRange: Range<Int> {
let dateComponents = DateComponents(year: selectedYear, month: selectedMonth)
let calendar = Calendar.current
let date = calendar.date(from: dateComponents)!
return calendar.range(of: .day, in: .month, for: date)!
}
}
And the part with pickers:
private extension ContentView {
var yearPicker: some View {
Picker("Years", selection: $selectedYear) {
ForEach(yearArray, id: \.self) { year in
Text(String(year)) // <- no need for `.tag()` if the `id` parameter in `ForEach` is specified
}
}
.pickerStyle(SegmentedPickerStyle())
}
var monthPicker: some View {
Picker("Month", selection: $selectedMonth) {
ForEach(1...12, id: \.self) { month in
Text(self.monthArray[month - 1])
}
}
.padding(.bottom, 2)
}
}

DatePicker on Mac not saving date until return key is pressed

I'm adapting my iPad app to Mac with Mac Catalyst and am having a problem with the datePicker (it has a datePickerMode of time). On iPad the datePicker is a wheel and whenever the user scrolls on the date picker the dateChanged action is fired. But on Mac the date picker is not a scroller and is instead a type of text input. I can type and change all the time values on Mac, but the dateChanged action won't be fired until I press the return key.
I would like to get the dateChange action fired whenever a user is entering in a time. How can I do this? I tried adding different targets to the datePicker but nothing work.
I actually prefer to have the date scroller on the Mac so if anyone knows how to do this instead I would greatly appreciate it (I looked all over the internet for this and found nothing)!
Here's my code:
class DateVC: UIViewController {
#IBOutlet weak var datePicker: UIDatePicker!
override func viewDidLoad() {
super.viewDidLoad()
//Just show the time
datePicker.datePickerMode = .time
}
//Action connected to datePicker. This is not called until I press enter on Mac
#IBAction func datePickerChanged(_ sender: Any) {
//do actions
}
}
I have filed a bug report with Apple about 1 week ago.
For now I a doing the following to force the datepicker to use the wheel format. This fires the onchangedlistener as the wheels are spun.
if #available(macCatalyst 13.4, *) {
datePickerView.preferredDatePickerStyle = .wheels
}
Because your function linked with #IBAction which is to be called upon action, like 'button press'
you should follow different approach.
let datePicker = UIDatePicker()
datePicker.datePickerMode = .date
dateTextField.inputView = datePicker
datePicker.addTarget(self, action: #selector(datePickerChanged(picker:)), for: .valueChanged)
and here is your function:
#objc func datePickerChanged(picker: UIDatePicker) {
//do your action here
}
Here is a SwiftUI solution that displays a date and time scroller picker on iPad, iPhone and Mac Catalyst. Works without pressing the return key. You can easily display just the HoursMinutesPicker if desired.
import SwiftUI
struct ContentView: View {
#State var date = Date()
var body: some View {
NavigationView {
NavigationLink(destination: DateHMPicker(date: self.$date)) {
VStack {
Text("Show time")
Text("\(self.date)")
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct DateHMPicker: View {
var titleKey: LocalizedStringKey = ""
#Binding var date: Date
var body: some View {
HStack {
Spacer()
DatePicker(titleKey, selection: self.$date, displayedComponents: .date).datePickerStyle(WheelDatePickerStyle())
HoursMinutesPicker(date: self.$date)
Spacer()
}
}
}
struct HoursMinutesPicker: View {
#Binding var date: Date
#State var hours: Int = 0
#State var minutes: Int = 0
var body: some View {
HStack {
Spacer()
Picker("", selection: Binding<Int>(
get: { self.hours},
set : {
self.hours = $0
self.update()
})) {
ForEach(0..<24, id: \.self) { i in
Text("\(i) hours").tag(i)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 90).clipped()
Picker("", selection: Binding<Int>(
get: { self.minutes},
set : {
self.minutes = $0
self.update()
})) {
ForEach(0..<60, id: \.self) { i in
Text("\(i) min").tag(i)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 90).clipped()
Spacer()
}.onAppear(perform: loadData)
}
func loadData() {
self.hours = Calendar.current.component(.hour, from: date)
self.minutes = Calendar.current.component(.minute, from: date)
}
func update() {
if let newDate = Calendar.current.date(bySettingHour: self.hours, minute: self.minutes, second: 0, of: date) {
date = newDate
}
}
}
How about using DatePicker for the date part, and the following textfields for the time input part.
import SwiftUI
import Combine
struct ContentView: View {
#State var date = Date()
var body: some View {
NavigationView {
NavigationLink(destination: DateHMPicker(date: self.$date)) {
VStack {
Text("Show time")
Text("\(self.date)")
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct DateHMPicker: View {
#State var labelText = ""
#Binding var date: Date
var body: some View {
HStack {
Spacer()
DatePicker(labelText, selection: self.$date, displayedComponents: .date)
Spacer()
HoursMinutesPicker(date: self.$date).frame(width: 90)
}.fixedSize()
}
}
struct TextFieldTime: View {
let range: ClosedRange<Int>
#Binding var value: Int
var handler: () -> Void
#State private var isGood = false
#State private var textValue = ""
#State private var digits = 2
var body: some View {
TextField("", text: $textValue)
.font(Font.body.monospacedDigit())
.onReceive(Just(textValue)) { txt in
// must be numbers
var newTxt = txt.filter {"0123456789".contains($0)}
if newTxt == txt {
// restrict the digits
if newTxt.count > self.digits {
newTxt = String(newTxt.dropLast())
}
// check the number
self.isGood = false
if let number = NumberFormatter().number(from: newTxt) {
if self.range.contains(number.intValue) {
self.textValue = newTxt
self.value = number.intValue
self.isGood = true
} else {
self.textValue = self.textValue.count == 1
? String(self.range.lowerBound) : String(self.textValue.dropLast())
}
}
if self.value >= 0 && self.isGood {
self.handler()
}
} else {
self.textValue = newTxt.isEmpty ? String(self.range.lowerBound) : newTxt
}
}.onAppear(perform: {
self.textValue = String(self.value)
self.digits = String(self.range.upperBound).count
})
.fixedSize()
}
}
struct HoursMinutesPicker: View {
#Binding var date: Date
#State var separator = ":"
#State var hours: Int = 0
#State var minutes: Int = 0
var body: some View {
HStack (spacing: 1) {
TextFieldTime(range: 0...23, value: self.$hours, handler: self.update)
Text(separator)
TextFieldTime(range: 0...59, value: self.$minutes, handler: self.update)
}.onAppear(perform: loadData).padding(5)
}
func loadData() {
self.hours = Calendar.current.component(.hour, from: date)
self.minutes = Calendar.current.component(.minute, from: date)
}
func update() {
let baseDate = Calendar.current.dateComponents([.year, .month, .day], from: date)
var dc = DateComponents()
dc.year = baseDate.year
dc.month = baseDate.month
dc.day = baseDate.day
dc.hour = self.hours
dc.minute = self.minutes
if let newDate = Calendar.current.date(from: dc), date != newDate {
date = newDate
}
}
}
I didn't find a solution to this using the built in UIDatePicker. I ended up moving to using JBCalendarDatePicker which accomplished the same look/feel.
If you prefer the wheel format of the DatePicker in Mac Catalyst, then change your Date Picker style to Wheels. It will display correctly on the Mac.
I converted my iPhone/iPad app to run on Mac Catalyst. I'm using Xcode 11.4.1 on MacOS Catalina 10.15.5. I was having a problem displaying the DatePicker as a wheel on the Mac version of the app. On the Mac emulator, it displayed as a text field, but the calendar would display when clicking on one of the date fields. I felt it would be a better user experience to stay with the wheel display.
A very simple workaround is to select the DatePicker in Storyboard. Display the Attributes Inspector. Change your Style from "Automatic" to "Wheels", and the display will go back to displaying as a wheel on the Mac Catalyst version.