I am fairly new in Swift(UI) and I am just trying to fiddle around and create myself a timeline like in the Calendar iOS app with the hours on the left and a divider per hour plus a red line where the current time is at.
Like this
I've seem to accomplish the first (hour and divider), but I am stuck on drawing the current time and position it correctly based on time and spacing.
Like this
Is anyone able to help me or guide me to the right direction? Thank you in advance!
My current code:
struct ContentView: View {
let dateFormatter = DateFormatter()
init(){
dateFormatter.dateFormat = "HH:mm"
}
var sortedTimeFrom: [Date] = {
let today = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date())!
var sortedTime: [Date] = []
var calender = Calendar(identifier: .iso8601)
calender.locale = Locale(identifier: "en_US_POSIX")
let currentHour = calender.component(.hour, from: today)
(0...(24)).forEach {
guard let newDate = calender.date(byAdding: .hour, value: $0, to: today),
calender.component(.hour, from: newDate) <= 23 else {
return
}
//convert date into desired format
sortedTime.append(newDate)
}
return sortedTime
}()
var body: some View {
ScrollView(.vertical)
{
ForEach(sortedTimeFrom, id: \.self) { date in
VStack(alignment: .leading){
HStack {
Spacer()
Text(date, formatter: dateFormatter)
VStack{
Color.gray.frame(height: 1 / UIScreen.main.scale)
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
You'll have to check every row for when the hour is equal to now, then calculate the offset based on the minutes & height of each row:
struct ContentView: View {
let dateFormatter = DateFormatter()
init(){
dateFormatter.dateFormat = "HH:mm"
}
var sortedTimeFrom: [Date] = {
let today = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date())!
var sortedTime: [Date] = []
var calender = Calendar(identifier: .iso8601)
calender.locale = Locale(identifier: "en_US_POSIX")
let currentHour = calender.component(.hour, from: today)
(0...(24)).forEach {
guard let newDate = calender.date(byAdding: .hour, value: $0, to: today),
calender.component(.hour, from: newDate) <= 23 else {
return
}
//convert date into desired format
sortedTime.append(newDate)
}
return sortedTime
}()
var currentDate: (Int, Double) {
let calendar = Calendar(identifier: .iso8601)
let hour = calendar.component(.hour, from: Date())
let minutes: Double = Double(calendar.component(.month, from: Date()))/60
return (hour, minutes)
}
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 0) {
ForEach(sortedTimeFrom, id: \.self) { date in
VStack(alignment: .leading){
HStack {
Spacer()
Text(date, formatter: dateFormatter)
VStack{
Color.gray.frame(height: 1 / UIScreen.main.scale)
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 30)
.overlay(
VStack {
if Calendar(identifier: .iso8601).component(.hour, from: date) == currentDate.0 {
HStack {
Spacer()
Text(Date(), formatter: dateFormatter)
.foregroundColor(.red)
Color.red
.frame(height: 1 / UIScreen.main.scale)
}.offset(y: 30 * currentDate.1)
}
}
)
}
}
}
}
}
Related
Using iOS16.3, XCode14.2, Swift5.7.2,
I try to create a nice looking Date-Picker where you can tap on a starting date and then tap on an ending date. And the entire date-range shall be highlighted.
Since this does not seem to exist even with the new MultiDatePicker under iOS16 and SwiftUI, I wonder if there is a good library out there to fulfill that task.
Moreover, I would like to add presets of DateRanges, such as this week, last week, this month, last month etc.
Here is what I tried in Code to achieve the week preset (see code below).
The problem is that once the user taps into the dates, then the preset-button no longer works.
Any idea how to achieve all this ?
import SwiftUI
struct CalendarView: View {
#Environment(\.calendar) var calendar
#Environment(\.timeZone) var timeZone
var bounds: Range<Date> {
let start = calendar.date(from: DateComponents(
timeZone: timeZone, year: 2022, month: 6, day: 1))!
let end = calendar.date(from: DateComponents(
timeZone: timeZone, year: 2023, month: 6, day: 30))!
return start ..< end
}
#State var dates: Set<DateComponents> = []
var body: some View {
VStack {
MultiDatePicker("Dates Available", selection: $dates, in: bounds)
HStack {
Button {
dates = []
let calendar = Calendar.current
let startDateComponents = DateComponents(year: 2023, month: 1, day: 15)
let endDateComponents = DateComponents(year: 2023, month: 2, day: 10)
let startDate = calendar.date(from: startDateComponents)!
let endDate = calendar.date(from: endDateComponents)!
var currentDate = startDate
while currentDate <= endDate {
dates.insert(calendar.dateComponents([.year, .month, .day], from: currentDate))
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
}
} label: {
Text("week")
}
}
Spacer()
.frame(height: 100)
}
}
}
#workingdog:
Here is the screenshot when trying to add RKCalendar with SPM:
Ah and by the way: as an improvement, I suggest the following small changes to your horizontalView.
If you are using isContinuous = false and isVertical = false then I suggest the following replacements:
replace:
ScrollView(.horizontal) by ScrollView(.horizontal, showsIndicators: false)
and 2. replace:
HStack by HStack(alignment: .center, spacing: 0)
This makes the PageView fit into the center (and is not off by some strange offset once you swipe).
public var horizontalView: some View {
GeometryReader { geometry in
// change-suggestions here.....
ScrollView(.horizontal, showsIndicators: false) {
// and change-suggestions here.....
HStack(alignment: .center, spacing: 0) {
ForEach(pages) { page in
page.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}
.content.offset(x: isGestureActive ? offset : -geometry.size.width * CGFloat(index))
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.simultaneousGesture(DragGesture()
.onChanged({ value in
isGestureActive = true
offset = value.translation.width - geometry.size.width * CGFloat(index)
})
.onEnded({ value in
if abs(value.predictedEndTranslation.width) >= geometry.size.width / 2 {
var nextIndex: Int = (value.predictedEndTranslation.width < 0) ? 1 : -1
nextIndex += index
index = nextIndex.keepIndexInRange(min: 0, max: pages.endIndex - 1)
}
withAnimation { offset = -geometry.size.width * CGFloat(index) }
DispatchQueue.main.async { self.isGestureActive = false }
})
)
}.onAppear(perform: { index = todayIndex() })
}
I am trying to create a UI like the below. I have a startDate and endDate (e.g. 1:55am and 11:35am). How can I proportionally (using Geometry reader) find each hour in between two dates and plot them as is done here? I am thinking at some point I need to use seconds or timeIntervalSince perhaps, but can't quite get my head around how best to go about it?
my code so far which gets the hours correctly, but I need to space them out proportionally according to their times:
What my code looks like:
struct AsleepTimeView: View {
#EnvironmentObject var dataStore: DataStore
static let sleepTimeFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
var body: some View {
Color.clear.overlay(
GeometryReader { geometry in
if let mostRecentSleepDay = dataStore.pastSevenSleepDays?.last, let firstSleepSpan = mostRecentSleepDay.sleepSpans.first, let lastSleepSpan = mostRecentSleepDay.sleepSpans.last {
VStack(alignment: .center) {
HStack {
Text("\(firstSleepSpan.startDate, formatter: Self.sleepTimeFormat) ")
.font(.footnote)
Spacer()
Text("\(lastSleepSpan.endDate, formatter: Self.sleepTimeFormat)")
.font(.footnote)
}
HStack(spacing: 3) {
ForEach(dataStore.sleepOrAwakeSpans) { sleepOrAwakeSpan in
RoundedRectangle(cornerRadius: 5)
.frame(width: getWidthForRoundedRectangle(proxy: geometry, spacing: 3, seconds: sleepOrAwakeSpan.seconds, sleepOrAwakeSpans: athlyticDataStore.sleepOrAwakeSpans), height: 10)
.foregroundColor(sleepOrAwakeSpan.asleep == false ? TrackerConstants.scaleLevel5Color : TrackerConstants.scaleLevel8Color)
}
}
HStack {
ForEach(getHoursBetweenTwoDates(startDate: firstSleepSpan.startDate, endDate: lastSleepSpan.endDate).map { Calendar.current.component(.hour, from: $0) }, id: \.self) { hour in
HStack {
Text("\(hour)")
.font(.footnote)
Spacer()
}
}
}
HStack {
HStack {
Circle()
.foregroundColor(TrackerConstants.scaleLevel8Color)
.frame(width: 10, height: 10)
Text("Asleep")
.font(.footnote)
}
HStack {
Circle()
.foregroundColor(TrackerConstants.scaleLevel5Color)
.frame(width: 10, height: 10)
Text("Awake")
.font(.footnote)
}
Spacer()
}
}
}
}) // end of overlay
}
//helper
private func getWidthForRoundedRectangle(proxy: GeometryProxy, spacing: Int, seconds: TimeInterval, sleepOrAwakeSpans: [SleepOrAwakeSpan]) -> CGFloat {
let totalSpace = (sleepOrAwakeSpans.count - 1) * spacing
let totalSleepTime = sleepOrAwakeSpans.map { $0.endTime.timeIntervalSince($0.startTime) }.reduce(0, +)
guard totalSleepTime > 0 else { return 0}
let width = (proxy.size.width - CGFloat(totalSpace)) * CGFloat(seconds / totalSleepTime)
return width
}
func datesRange(from: Date, to: Date, component: Calendar.Component) -> [Date] {
// in case of the "from" date is more than "to" date,
// it should returns an empty array:
if from > to { return [Date]() }
var tempDate = from
var array = [tempDate]
while tempDate < to {
tempDate = Calendar.current.date(byAdding: component, value: 1, to: tempDate)!
array.append(tempDate)
}
return array
}
func getHoursBetweenTwoDates(startDate: Date, endDate: Date) -> [Date] {
var finalArrayOfHours = [Date]()
guard endDate > startDate else { return finalArrayOfHours }
let arrayOfHours = datesRange(from: startDate, to: endDate, component: .hour)
for date in arrayOfHours {
let hour = date.nearestHour()
finalArrayOfHours.append(hour)
}
return finalArrayOfHours
}
}
extension Date {
func nearestHour() -> Date {
return Date(timeIntervalSinceReferenceDate:
(timeIntervalSinceReferenceDate / 3600.0).rounded(.toNearestOrEven) * 3600.0)
}
}
There are a whole suite of methods in the Calendar class to help with what you're trying to do.
Off the top of my head, Id say you'd do something like the following:
In a loop, use date(byAdding:to:wrappingComponents:) to add an hour at a time to your startDate. If the result is ≤ endDate, use component(_:from:) to get the hour of the newly calculated date.
I built a simple horizontal calendar with SwiftUI and it works fine. But I want that it automatically scroll to the current date at app launch. How can I do this? I assume I should use GeometryReader to get a position of frame with current date and set an offset based on it but ScrollView doesn't have a content offset modifier. I know that in iOS 14 we now have ScrollViewReader but what about iOS 13?
struct MainCalendar: View {
#Environment(\.calendar) var calendar
#State private var month = Date()
#State private var selectedDate: Date = Date()
var body: some View {
VStack(spacing: 30) {
MonthAndYearView(date: $month)
WeekDaysView(date: month)
}
.padding(.vertical)
}
}
struct MonthAndYearView: View {
#Environment(\.calendar) var calendar
private let formatter = DateFormatter.monthAndYear
#Binding var date: Date
var body: some View {
HStack(spacing: 20) {
Button(action: {
self.date = calendar.date(byAdding: .month, value: -1, to: date)!
}, label: {
Image(systemName: "chevron.left")
})
Text(formatter.string(from: date))
.font(.system(size: 18, weight: .semibold))
Button(action: {
self.date = calendar.date(byAdding: .month, value: 1, to: date)!
}, label: {
Image(systemName: "chevron.right")
})
}
}
}
struct WeekDaysView: View {
#Environment(\.calendar) var calenar
let date: Date
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 30) {
ForEach(days, id: \.self) { day in
VStack(spacing: 20) {
Text(daySymbol(date: day))
.font(.system(size: 14, weight: .regular))
if calenar.isDateInToday(day) {
Text("\(dayNumber(date: day))")
.foregroundColor(Color.white)
.background(Circle().foregroundColor(.blue)
.frame(width: 40, height: 40))
} else {
Text("\(dayNumber(date: day))")
}
}
}
}
.padding([.horizontal])
.padding(.bottom, 15)
}
}
private func dayNumber(date: Date) -> String {
let formatter = DateFormatter.dayNumber
let dayNumber = formatter.string(from: date)
return dayNumber
}
private var days: [Date] {
guard let interval = calenar.dateInterval(of: .month, for: date) else { return [] }
return calenar.generateDates(inside: interval, matching: DateComponents(hour: 0, minute: 0, second: 0))
}
private func daySymbol(date: Date) -> String {
let dayFormatter = DateFormatter.weekDay
let weekDay = dayFormatter.string(from: date)
return weekDay
}
}
This library https://github.com/Amzd/ScrollViewProxy is solved my problem.
struct WeekDaysView: View {
let date: Date
#Environment(\.calendar) var calendar
#State private var scrollTarget: Int? = nil
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 30) {
ForEach(Array(zip(days.indices, days)), id: \.0) { index, day in
VStack(spacing: 20) {
Text(daySymbol(date: day))
.font(.system(size: 14, weight: .regular))
.scrollId(index)
if calendar.isDateInToday(day) {
Text("\(dayNumber(date: day))")
.foregroundColor(Color.white)
.background(Circle().foregroundColor(.blue)
.frame(width: 40, height: 40))
.onAppear {
scrollTarget = index
}
} else {
Text("\(dayNumber(date: day))")
}
}
}
}
.padding([.horizontal])
.padding(.bottom, 15)
}
.onAppear {
DispatchQueue.main.async {
withAnimation {
proxy.scrollTo(scrollTarget, alignment: .center, animated: true)
}
}
}
}
}
I have a simple View with a custom month selector. Each time you click chevron left or right,
Inside DateView I had two private vars: startDateOfMonth2: Date and endDateOfMonth2: Date. But they were only available inside DateView.
QUESTION
How to make those two variables available in other views?
I have tried to add them as #Published vars, but I am getting an error:
Property wrapper cannot be applied to a computed property
I have found just a few similar questions, but I cannot manage to use answers from them with my code.
import SwiftUI
class SelectedDate: ObservableObject {
#Published var selectedMonth: Date = Date()
#Published var startDateOfMonth2: Date {
let components = Calendar.current.dateComponents([.year, .month], from: selectedMonth)
let startOfMonth = Calendar.current.date(from: components)!
return startOfMonth
}
#Published var endDateOfMonth2: Date {
var components = Calendar.current.dateComponents([.year, .month], from: selectedMonth)
components.month = (components.month ?? 0) + 1
let endOfMonth = Calendar.current.date(from: components)!
return endOfMonth
}
}
struct DateView: View {
#EnvironmentObject var selectedDate: SelectedDate
static let dateFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("yyyy MMMM")
return formatter
}()
var body: some View {
HStack {
Image(systemName: "chevron.left")
.frame(width: 50, height: 50)
.contentShape(Rectangle())
.onTapGesture {
self.changeMonthBy(-1)
}
Spacer()
Text("\(selectedDate.selectedMonth, formatter: Self.dateFormat)")
Spacer()
Image(systemName: "chevron.right")
.frame(width: 50, height: 50)
.contentShape(Rectangle())
.onTapGesture {
self.changeMonthBy(1)
}
}
.padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
.background(Color.yellow)
}
func changeMonthBy(_ months: Int) {
if let selectedMonth = Calendar.current.date(byAdding: .month, value: months, to: selectedDate.selectedMonth) {
self.selectedDate.selectedMonth = selectedMonth
}
}
}
No need to declare your computed values as Published, as they are depending on a Published value. When the published value changed, they get recalculated.
class SelectedDate: ObservableObject {
#Published var selectedMonth: Date = Date()
var startDateOfMonth2: Date {
let components = Calendar.current.dateComponents([.year, .month], from: self.selectedMonth)
let startOfMonth = Calendar.current.date(from: components)!
print(startOfMonth)
return startOfMonth
}
var endDateOfMonth2: Date {
var components = Calendar.current.dateComponents([.year, .month], from: self.selectedMonth)
components.month = (components.month ?? 0) + 1
let endOfMonth = Calendar.current.date(from: components)!
print(endOfMonth)
return endOfMonth
}
}
When you print endDateOfMonth2 on click, you will see that it is changing.
computed property is function substantially。
it can't stored value,it just computed value from other variable or property in getter and update value back to things it computed in setter。
think about your property‘s character carefully。
mostly,you can directly readwrite computed property。
just remember it is function core and property skin
How to get startDateOfMonth and endDateOfMonth based on selected date in SwiftUI?
I have found some answers for Swift (DateComponents), but couldn't make it work with SwiftUI.
Why I need this: I am going to use dynamic filters using predicate to filter all the data in the currently selected month (using custom control to switch months). But first I need to get the start and end dates per selected month.
EXAMPLE code:
ContentView.swift
import SwiftUI
struct ContentView: View {
#State var currentDate = Date()
// How to make startDateOfMonth and endDateOfMonth dependent on selected month?
#State private var startDateOfMonth = "1st January"
#State private var endDateOfMonth = "31st January"
var body: some View {
VStack {
DateView(date: $currentDate)
Text("\(currentDate)")
Text(startDateOfMonth)
Text(endDateOfMonth)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
DateView.swift
import SwiftUI
struct DateView: View {
static let dateFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("yyyy MMMM")
return formatter
}()
#Binding var date : Date
var body: some View {
HStack {
Image(systemName: "chevron.left")
.padding()
.onTapGesture {
print("Month -1")
self.changeDateBy(-1)
}
Spacer()
Text("\(date, formatter: Self.dateFormat)")
Spacer()
Image(systemName: "chevron.right")
.padding()
.onTapGesture {
print("Month +1")
self.changeDateBy(1)
}
}
.padding(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10))
.background(Color.yellow)
}
func changeDateBy(_ months: Int) {
if let date = Calendar.current.date(byAdding: .month, value: months, to: date) {
self.date = date
}
}
}
struct DateView_Previews: PreviewProvider {
struct BindingTestHolder: View {
#State var testItem: Date = Date()
var body: some View {
DateView(date: $testItem)
}
}
static var previews: some View {
BindingTestHolder()
}
}
I managed to solve it by the following implementation of ContentView
#State var currentDate = Date()
private var startDateOfMonth: String {
let components = Calendar.current.dateComponents([.year, .month], from: currentDate)
let startOfMonth = Calendar.current.date(from: components)!
return format(date: startOfMonth)
}
private var endDateOfMonth: String {
var components = Calendar.current.dateComponents([.year, .month], from: currentDate)
components.month = (components.month ?? 0) + 1
components.hour = (components.hour ?? 0) - 1
let endOfMonth = Calendar.current.date(from: components)!
return format(date: endOfMonth)
}
var body: some View {
VStack {
DateView(date: $currentDate)
Text("\(currentDate)")
Text(startDateOfMonth)
Text(endDateOfMonth)
}
}
private func format(date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
return dateFormatter.string(from: date)
}
Because currentDate is changed by DateView through Binding the body computed property will be invoked thus startDateOfMonth and endDateOfMonth computed properties will return the updated values.