Swift: Finding and plotting the hours in between two Dates - ios

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.

Related

Date-range-presets with SwiftUI's new MultiDatePicker

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() })
}

How to create a timeline like the iOS calendar app?

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)
}
}
)
}
}
}
}
}

Changing chart data in IOS

So I am having trouble with some charting in Swift. I am using the following charting pod: https://github.com/aunnnn/RHLinePlot
I am using UIKit so I inserted a swiftUI view into my view controller. The charts work fine but whenever I change to a different timeline some data seems to be stuck from other timeperiods. But only at the 0 X axis value. You can see it on the left side, sticking out from the vertical Y axis. The first picture is fine but then the second shows some of the first chart, then the third shows of both:
Seems like some data is stuck in the view but I use new datasets every time.
Code goes as follows:
func setSwiftUi() {
let formView = SwiftUIStockChart(data: self.chartDataSwiftUI) { [weak self] newData in
self?.dismiss(animated: true) {
}
}
let childView = UIHostingController(rootView: formView)
addChild(childView)
childView.view.frame = chartView.bounds
chartView.addSubview(childView.view)
childView.didMove(toParent: self)
self.chartView.setNeedsDisplay()
self.chartViewAndAxis.setNeedsDisplay()
}
struct SwiftUIStockChart: View {
#State var currentIndex: Int? = nil
#State private var data = SymbolInfoStruct()
private var completion: (SymbolInfoStruct) -> Void
init(data: SymbolInfoStruct, completion: #escaping (SymbolInfoStruct) -> Void) {
self._data = State(initialValue: data)
self.completion = completion
}
func valueStickLabel(value: SymbolInfoStruct) -> some View {
let currentIndex = self.currentIndex ?? (value.chartDates.count - 1)
let formatter = DateFormatter()
if value.dateRange == "day"{
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
let date = formatter.date(from: value.chartDates[currentIndex])!
formatter.dateFormat = "HH:mm"
return Text(formatter.string(from: date))
} else if value.dateRange == "week" {
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
let date = formatter.date(from: value.chartDates[currentIndex])!
formatter.dateFormat = "EE"
return Text(formatter.string(from: date))
} else if value.dateRange == "month" {
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
let date = formatter.date(from: value.chartDates[currentIndex])!
formatter.dateFormat = "MMM d"
return Text(formatter.string(from: date))
} else if value.dateRange == "year" {
formatter.dateFormat = "yyyy-MM-dd"
let date = formatter.date(from: value.chartDates[currentIndex])!
formatter.dateFormat = "MMMM"
return Text(formatter.string(from: date))
} else {
formatter.dateFormat = "yyyy-MM-dd"
let date = formatter.date(from: value.chartDates[currentIndex])!
formatter.dateFormat = "YYYY"
return Text(formatter.string(from: date))
}
}
var isLaserModeOn = false
var showGlowingIndicator: Bool {
switch data.interval {
case "day":
return true // simulate today's data
default:
return false
}
}
func buildMovingPriceLabel(plotData: [CGFloat]) -> some View {
var currentIndex = 0
currentIndex = self.currentIndex ?? (plotData.count - 1)
return HStack(alignment: .bottom, spacing: 1) {
Text(" $")
MovingNumbersView(
number: Double(plotData[currentIndex]),
numberOfDecimalPlaces: 2,
verticalDigitSpacing: 0,
animationDuration: 0.3,
fixedWidth: 100) { (digit) in
Text(digit)
}
.mask(LinearGradient(
gradient: Gradient(stops: [
Gradient.Stop(color: .clear, location: 0),
Gradient.Stop(color: .orange, location: 0.2),
Gradient.Stop(color: .orange, location: 0.8),
Gradient.Stop(color: .orange, location: 1.0)]),
startPoint: .top,
endPoint: .bottom))
}
.rhFont(style: .title3, weight: .medium)
.foregroundColor(.orange.opacity(0.9))
}
var body: some View {
VStack (alignment: .leading, spacing: -10){
RHInteractiveLinePlot(
values: data.chartDataPoints,
occupyingRelativeWidth: 0.9,
showGlowingIndicator: true,
lineSegmentStartingIndices: Array(stride(from: 0, to: data.chartDataPoints.count, by: 5)),
didSelectValueAtIndex: { index in
self.currentIndex = index
},
customLatestValueIndicator: {
},
valueStickLabel: { value in
valueStickLabel(value: data)
})
.foregroundColor(.orange.opacity(0.9))
.font(.headline)
.environment(\.rhLinePlotConfig, RHLinePlotConfig.default.custom(f: { (c) in
c.useLaserLightLinePlotStyle = isLaserModeOn
}))
buildMovingPriceLabel(plotData: data.chartDataPoints)
}
}
}
Any idea how to resolve this?

Wrong selection with LazyHGrid SwiftUI

Hi all I have a problem with selecting cells in a LazyHGrid using SwiftUI
In the TimePickerGridView.swift I create a horizontal list with times that the user can select. To manage cell selection I use #State private var selectedItem: Int = 0
Everything seems to work but I have some problems when I save the user selection to create a date which contains the selected times
When the user selects a cell, it immediately updates a date by setting the hours and minutes.
The date being modified is #Binding var date: Date, this date refers to a RiservationViewModel.swift which is located in the RiservationView structure
struct ReservationsView: View {
#StateObject var viewModels = ReservationViewModel()
var body: some View {
VStack {
TimePickerGridView(date: $viewModels.selectedDate)
}
}
}
Now the problem is that when the user selects the time and creates the date the LazyHGrid continuously loses the selection and has to select the same cell more than once to get the correct selection again ...
At this point the variable date is observed because it can change thanks to another view that contains a calendar where the user selects the day.
How can I solve this problem? where is my error?
private extension Date {
var hour: Int { Calendar.current.component(.hour, from: self) }
var minute: Int { Calendar.current.component(.minute, from: self) }
var nextReservationTime: TimePicker {
let nextHalfHour = self.minute < 30 ? self.hour : (self.hour + 1) % 24
let nextHalfMinute = self.minute < 30 ? 30 : 0
return TimePicker(hour: nextHalfHour, minute: nextHalfMinute)
}
}
struct TimePickerGridView: View {
#Binding var date: Date
#State private var selectedItem: Int = 0
#State private var showNoticeView: Bool = false
private var items: [TimePicker] = TimePicker.items
private func setTimeForDate(_ time: (Int, Int)) {
guard let newDate = Calendar.current.date(bySettingHour: time.0, minute: time.1, second: 0, of: date) else { return }
date = newDate
}
private var startIndex: Int {
// se trova un orario tra quelli della lista seleziona l'index appropriato
// altrimenti seleziona sempre l'index 0
items.firstIndex(of: date.nextReservationTime) ?? 0
}
init(date: Binding<Date>) {
// Date = ReservationViewModel.date
self._date = date
}
var body: some View {
VStack(spacing: 20) {
HStack {
Spacer()
TitleView("orario")
VStack {
Divider()
.frame(width: screen.width * 0.2)
}
Button(action: {}) {
Text("RESET")
.font(.subheadline.weight(.semibold))
.foregroundColor(date.isToday() ? .gray : .bronze)
.padding(.vertical, 5)
.padding(.horizontal)
.background(.thinMaterial)
.clipShape(Capsule())
}
Spacer()
}
ZStack {
ScrollView(.horizontal, showsIndicators: false) {
ScrollViewReader { reader in
LazyHGrid(rows: Array(repeating: GridItem(.fixed(60), spacing: 0), count: 2), spacing: 0) {
ForEach(items.indices) { item in
TimePickerGridCellView(date: $date, selectedItem: $selectedItem, picker: items[item], selection: selectedItem == items.indices[item])
.frame(width: (UIScreen.main.bounds.width - horizontalDefaultPadding * 2) / 4)
.onTapGesture {
setTimeForDate((items[item].hour, items[item].minute))
selectedItem = item
}
.onChange(of: date) { _ in
selectedItem = startIndex
}
.onAppear(perform: {
selectedItem = startIndex
})
}
}
.background(Divider())
}
}
.frame(height: showNoticeView ? 70 : 130)
}
}
}
}

Content offset in ScrollView SwiftUI

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)
}
}
}
}
}

Resources