Skip to content

Instantly share code, notes, and snippets.

@KyLeggiero
Last active August 8, 2018 18:55
Show Gist options
  • Save KyLeggiero/b31c53cfe30ea3b6e3e6ff71adbf309f to your computer and use it in GitHub Desktop.
Save KyLeggiero/b31c53cfe30ea3b6e3e6ff71adbf309f to your computer and use it in GitHub Desktop.
Create a datetime format with clear and concise Swift enums instead of a cryptic string
// Copyright Ben Leggiero 2017 BH-1-PS
// https://github.com/BlueHuskyStudios/Licenses/blob/master/Licenses/BH-1-PS.txt
import Foundation
// MARK: Types and functionality
private let millisecondsPerSecond: TimeInterval = 1000
private let secondsPerMinute: TimeInterval = 60
private let minutesPerHour: TimeInterval = 60
private let secondsPerHour: TimeInterval = secondsPerMinute * minutesPerHour
private let hoursPerDay: TimeInterval = 24
private let secondsPerDay: TimeInterval = secondsPerHour * hoursPerDay
private let daysPerWeek: TimeInterval = 7
private let secondsPerWeek: TimeInterval = secondsPerDay * daysPerWeek
private let daysPerYear: TimeInterval = 365.24219
private let secondsPerYear: TimeInterval = secondsPerDay * daysPerYear
struct DatetimeFormatter {
public static let shared = DatetimeFormatter()
var calendar: Calendar
/// The separator used between two adjacent non-`.separator` datetime pieces
var defaultSeparator: String
init(calendar: Calendar = .autoupdatingCurrent, defaultSeparator: String = " ") {
self.calendar = calendar
self.defaultSeparator = defaultSeparator
}
func string(from date: Date, as format: [DatetimePiece]) -> String {
return string(from: date, as: format.map { StyledDatetimePiece(piece: $0, representation: .auto, calendar: self.calendar) })
}
func string(from date: Date, as format: [StyledDatetimePiece]) -> String {
guard format.count > 0 else { return "" }
var separatedPieces = [format[0]]
(1..<format.count).forEach { formatIndex in
let previousPiece = format[formatIndex - 1]
let currentPiece = format[formatIndex]
let needsGeneratedSeparator: Bool
switch previousPiece.piece {
case .separator:
needsGeneratedSeparator = false
default:
switch currentPiece.piece {
case .separator:
needsGeneratedSeparator = false
default:
needsGeneratedSeparator = true
}
}
if needsGeneratedSeparator {
separatedPieces.append(StyledDatetimePiece(piece: .separator(separatorString: self.defaultSeparator),
representation: .auto,
calendar: self.calendar))
}
separatedPieces.append(currentPiece)
}
return separatedPieces.reduce("", { (formattedDate, styledDatetimePiece) -> String in
return formattedDate + styledDatetimePiece.description(of: date)
})
}
}
struct StyledDatetimePiece {
var piece: DatetimePiece
var representation: DatetimePieceRepresentation
var calendar: Calendar
}
extension StyledDatetimePiece {
func description(of date: Date) -> String {
switch piece {
case .separator(let separatorString):
return separatorString
case .millisecondsSince(let epoch):
return representationString(from: date.timeIntervalSince(epoch) * millisecondsPerSecond)
case .millisecondOfSecond:
return representationString(from: (millisecondsPerSecond * date.timeIntervalSinceReferenceDate).truncatingRemainder(dividingBy: millisecondsPerSecond))
case .secondsSince(let epoch):
return representationString(from: date.timeIntervalSince(epoch))
case .secondOfMinute:
let second = calendar.component(.second, from: date)
return representationString(from: second)
case .minutesSince(let epoch):
return representationString(from: date.timeIntervalSince(epoch) / secondsPerMinute)
case .minuteOfHour:
let minute = calendar.component(.minute, from: date)
return representationString(from: minute)
case .hoursSince(let epoch):
return representationString(from: date.timeIntervalSince(epoch) / secondsPerHour)
case .hourOfDay(let clockStyle):
let hour = calendar.component(.hour, from: date)
return representationString(from: hour % Int(clockStyle.hoursPerDay))
case .periodOfHour(let morningMarker, let eveningMarker, let onlyIfUserPrefersTwelveHour):
if onlyIfUserPrefersTwelveHour {
switch ClockStyle.userPreference {
case .twentyFourHour:
return ""
case .twelveHour:
break
}
}
let isMorning = calendar.component(.hour, from: date) < 12
if isMorning {
return morningMarker
} else {
return eveningMarker
}
case .daysSince(let epoch):
return representationString(from: date.timeIntervalSince(epoch) / secondsPerDay)
case .dayOfWeek:
let weekday = calendar.component(.weekday, from: date)
return representationString(from: weekday)
case .dayOfMonth:
let day = calendar.component(.day, from: date)
return representationString(from: day)
// case .dayOfYear: // TODO
case .weeksSince(let epoch):
return representationString(from: date.timeIntervalSince(epoch) / secondsPerWeek)
case .weekOfMonth(_): // FIXME: use includeFirstPartialWeek
let week = calendar.component(.weekOfMonth, from: date)
return representationString(from: week)
case .weekOfYear(_): // FIXME: includeFirstPartialWeek
let week = calendar.component(.weekOfYear, from: date)
return representationString(from: week)
case .monthOfYear:
let month = calendar.component(.month, from: date)
return representationString(from: month)
case .yearsSince(let epoch):
return representationString(from: date.timeIntervalSince(epoch) / secondsPerYear)
case .yearOfEra:
let year = calendar.component(.year, from: date)
return representationString(from: year)
}
}
private func representationString(from raw: TimeInterval) -> String {
return representation.string(from: raw, piece: piece)
}
private func representationString(from raw: Int) -> String {
return representationString(from: TimeInterval(raw))
}
}
enum DatetimePiece {
static let defaultMorningMarker = ClockStyle.defaultMorningMarker
static let defaultEveningMarker = ClockStyle.defaultEveningMarker
case separator(separatorString: String)
case millisecondsSince(epoch: Date)
case millisecondOfSecond
case secondsSince(epoch: Date)
case secondOfMinute
case minutesSince(epoch: Date)
case minuteOfHour
case hoursSince(epoch: Date)
case hourOfDay(clockStyle: ClockStyle)
case periodOfHour(morningMarker: String, eveningMarker: String, onlyIfUserPrefersTwelveHour: Bool)
case daysSince(epoch: Date)
case dayOfWeek
case dayOfMonth
// case dayOfYear // TODO
case weeksSince(epoch: Date)
case weekOfMonth(includeFirstPartialWeek: Bool)
case weekOfYear(includeFirstPartialWeek: Bool)
case monthOfYear
case yearsSince(epoch: Date)
case yearOfEra
}
extension DatetimePiece {
/// Used when `.auto` is selected as the representation. If this returns `.auto`, that means to just use its
/// native string description.
var defaultRepresentation: DatetimePieceRepresentation {
switch self {
case .separator:
return .auto
case .millisecondsSince,
.secondsSince,
.minutesSince,
.hoursSince,
.daysSince,
.weeksSince,
.yearsSince:
return .integerOnly(minimumDigits: 0)
case .millisecondOfSecond:
return .integerOnly(minimumDigits: 4)
case .secondOfMinute,
.minuteOfHour,
.hourOfDay,
.dayOfMonth,
.monthOfYear,
.weekOfYear:
return .integerOnly(minimumDigits: 2)
case .periodOfHour:
return .auto
case .dayOfWeek,
.weekOfMonth,
.yearOfEra:
return .integerOnly(minimumDigits: 1)
// case .dayOfYear:
// return .integerOnly(minimumDigits: 0)
}
}
}
enum ClockStyle {
static let defaultMorningMarker = "AM"
static let defaultEveningMarker = "PM"
case twelveHour(morningMarker: String, eveningMarker: String)
case twentyFourHour
}
extension ClockStyle {
private static var userLocale: Locale { return Locale.current }
private static func userPreferredHourMarker(at date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "a"
return dateFormatter.string(from: date)
}
private static var userPreferredMorningMarker: String {
return userPreferredHourMarker(at: Date(timeIntervalSinceReferenceDate: 60))
}
private static var userPreferredEveningMarker: String {
return userPreferredHourMarker(at: Date(timeIntervalSinceReferenceDate: 60 * 60 * 13))
}
static var userPreference: ClockStyle {
let userPreferenceIs12Hour: Bool
if let formatFromPreferences = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: NSLocale.current) {
// like "h a" for 12-hour and "HH" for 24-hour
userPreferenceIs12Hour = formatFromPreferences.contains("a")
} else {
userPreferenceIs12Hour = false
}
if userPreferenceIs12Hour {
return .twelveHour(morningMarker: userPreferredMorningMarker, eveningMarker: userPreferredEveningMarker)
} else {
return .twentyFourHour
}
}
var hoursPerDay: UInt {
switch self {
case .twelveHour:
return 12
case .twentyFourHour:
return 24
}
}
}
enum DatetimePieceRepresentation {
case auto
/// Only the integer part of the datetime piece.
/// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017 01 10"`
/// - Parameter minimumDigits: The smallest number of integer digits to display. `0` signifies that a value of `0`
/// results in an empty string.
case integerOnly(minimumDigits: UInt)
/// The integer and fractional parts of the datetime piece.
/// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017.02466 01.2903 10.4873"`
/// - Parameters:
/// minimumIntegerDigits: The smallest number of integer digits to display. `0` signifies that a value of `0`
/// results in an empty string.
/// maximumFractionDigits: The largest number of fractional digits to display. `0` results in this behaving
/// like `.integersOnly`.
case integerAndFractionalDigits(minimumIntegerDigits: UInt, maximumFractionDigits: UInt)
/// Only the fractional part of the datetime piece.
/// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `".02466 .2903 .4873"`
/// maximumFractionDigits: The largest number of fractional digits to display. `0` results in this behaving
/// like `.integersOnly`.
case onlyFractionalDigits(maximumFractionDigits: UInt)
/// The ordinal form of the number, like `1st`, `22nd`, `503rd`, etc.
case ordinal
// /// The short word used to describe the datetime piece.
// /// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017 Jan Ten"`
// case shortWord // TODO
//
// /// The short word used to describe the datetime piece.
// /// For example, `[.yearOfEra, .monthOfYear, .dayOfMonth]` would become `"2017 A.D. January Ten"`
// case longWord // TODO
/// A custom number formatter for the datetime piece
case customFormatted(customFormatter: NumberFormatter)
}
extension DatetimePieceRepresentation {
func string(from timeInterval: TimeInterval, piece: DatetimePiece) -> String {
let representation: DatetimePieceRepresentation
switch self {
case .auto:
representation = piece.defaultRepresentation
default:
representation = self
}
switch representation {
case .auto:
return timeInterval.description
case .integerOnly(let minimumDigits):
let formatter = NumberFormatter()
formatter.minimumIntegerDigits = Int(minimumDigits)
formatter.maximumFractionDigits = 0
return formatter.string(from: NSNumber(value: timeInterval))!
case .integerAndFractionalDigits(let minimumIntegerDigits, let maximumFractionDigits):
let formatter = NumberFormatter()
formatter.minimumIntegerDigits = Int(minimumIntegerDigits)
formatter.maximumFractionDigits = Int(maximumFractionDigits)
return formatter.string(from: NSNumber(value: timeInterval))!
case .onlyFractionalDigits(let maximumDigits):
let formatter = NumberFormatter()
formatter.maximumIntegerDigits = 0
formatter.maximumFractionDigits = Int(maximumDigits)
return formatter.string(from: NSNumber(value: timeInterval))!
case .ordinal:
return ordinalFormatter.string(from: NSNumber(value: timeInterval))!
// case .shortWord: // TODO
// case .longWord: // TODO
case .customFormatted(let customFormatter):
return customFormatter.string(from: NSNumber(value: timeInterval))!
}
}
private func integerPart(of float: CGFloat, minimumDigits: UInt) -> String {
if minimumDigits == 0, float == 0 {
return ""
} else {
return String(format: "%0\(minimumDigits)d", Int(float.rounded()))
}
}
private func fractionalPart(of float: CGFloat, maximumDigits: UInt) -> String {
let stringValue = Int(float - float.rounded(.towardZero)).description
let maximumDigits = Int(maximumDigits)
if stringValue.characters.count > maximumDigits {
return stringValue.substring(to: stringValue.index(stringValue.startIndex, offsetBy: maximumDigits))
} else {
return stringValue
}
}
}
private let ordinalFormatter: NumberFormatter = {
let ordinalFormatter = NumberFormatter()
ordinalFormatter.numberStyle = .ordinal
return ordinalFormatter
}()
// MARK: - Usage
var formatter = DatetimeFormatter.shared
print(formatter.string(from: Date(), as: [.yearOfEra, .monthOfYear, .dayOfMonth])) // like "2017 01 19"
formatter.defaultSeparator = "-"
print(formatter.string(from: Date(), as: [.yearOfEra, .monthOfYear, .dayOfMonth])) // like "2017-01-19"
formatter.defaultSeparator = ":"
print(formatter.string(from: Date(), as: [.hourOfDay(clockStyle: .userPreference), .minuteOfHour, .secondOfMinute])) // like "14:48:50" or "2:48:50"
formatter = DatetimeFormatter()
print(formatter.string(from: Date(), as: [.yearOfEra, .separator(separatorString: "-"),
.monthOfYear, .separator(separatorString: "-"),
.dayOfMonth,
.separator(separatorString: " at "),
.hourOfDay(clockStyle: .userPreference), .separator(separatorString: ":"),
.minuteOfHour, .separator(separatorString: ":"),
.secondOfMinute])) // like "2017-01-19 at 14:48:50" or "2017-01-19 at 02:48:50"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment