├── TachyonUITests ├── TachyonUITests-Bridging-Header.h ├── Info.plist └── TachyonUITests.swift ├── TachyonTests ├── TachyonTests-Bridging-Header.h ├── Day View │ ├── TCNDayViewTestsViewProvider.h │ ├── TCNDayViewTestsViewProvider.m │ ├── TCNEventCellTests.m │ └── TCNDayViewTests.m ├── Info.plist ├── Test Utilities │ ├── TCNTestUtils.h │ └── TCNTestUtils.m ├── Other │ └── TCNDateUtilTests.m ├── Date Picker │ └── TCNDatePickerTests.m └── Models │ └── TCNEventTests.swift ├── Resources ├── day_view.png └── date_picker.png ├── TachyonSampleApp ├── Assets.xcassets │ ├── Contents.json │ ├── ic_cancel_16dp.imageset │ │ ├── ic_cancel_16dp.png │ │ ├── ic_cancel_16dp@2x.png │ │ ├── ic_cancel_16dp@3x.png │ │ └── Contents.json │ ├── ic_cancel_24dp.imageset │ │ ├── ic_cancel_24dp.png │ │ ├── ic_cancel_24dp@2x.png │ │ ├── ic_cancel_24dp@3x.png │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Tachyon-Bridging-Header.h ├── LaunchScreen.xib ├── Info.plist ├── AppDelegate.swift ├── ViewController.swift └── SampleEvents.swift ├── Tachyon ├── Resources │ └── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ └── Contents.json ├── CollectionViewLayouts │ ├── TCNDecorationViewLayoutAttributes.m │ ├── TCNDecorationViewLayoutAttributes.h │ ├── TCNDayViewLayout+Protected.h │ ├── TCNAllDayViewLayout.h │ ├── TCNDatePickerLayout.h │ ├── TCNDatePickerLayout.m │ ├── TCNDayViewLayout.h │ └── TCNAllDayViewLayout.m ├── Views │ ├── TCNReusableView.h │ ├── TCNDatePickerSelectionIndicatorView.h │ ├── TCNDatePickerDayView.h │ ├── TCNDayViewGridlineView.h │ ├── TCNEventCell.h │ ├── TCNDayViewTimeView.h │ ├── TCNDayViewGridlineView.m │ ├── TCNDatePickerSelectionIndicatorView.m │ ├── TCNDayViewTimeView.m │ ├── TCNDatePickerDayView.m │ └── TCNEventCell.m ├── Public API │ ├── Tachyon.h │ ├── TCNDatePickerConfig.m │ ├── TCNDayViewConfig.m │ ├── TCNDatePickerView.h │ ├── TCNDatePickerConfig.h │ ├── Models │ │ ├── TCNEvent.h │ │ └── TCNEvent.m │ ├── TCNDayView.h │ ├── TCNDateUtil.h │ ├── TCNDayViewConfig.h │ ├── TCNDateUtil.m │ ├── TCNDatePickerView.m │ └── TCNDayView.m ├── Helpers │ ├── TCNDateFormatter+Testing.h │ ├── TCNNumberHelper.h │ ├── TCNNumberHelper.m │ ├── TCNViewUtils.h │ ├── TCNViewUtils.m │ ├── TCNDateFormatter.h │ ├── TCNMacros.h │ └── TCNDateFormatter.m └── DataSources │ ├── TCNDatePickerDataSource.h │ └── TCNDatePickerDataSource.m ├── NOTICE ├── .gitignore ├── Podfile ├── Tachyon.podspec ├── CONTRIBUTING.md ├── LICENSE ├── Tachyon.xcodeproj └── xcshareddata │ └── xcschemes │ ├── TachyonUITests.xcscheme │ ├── TachyonTests.xcscheme │ ├── TachyonSampleApp.xcscheme │ └── Tachyon.xcscheme └── README.md /TachyonUITests/TachyonUITests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "Tachyon.h" 2 | -------------------------------------------------------------------------------- /TachyonTests/TachyonTests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "Tachyon.h" 2 | #import "TCNTestUtils.h" 3 | -------------------------------------------------------------------------------- /Resources/day_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/Resources/day_view.png -------------------------------------------------------------------------------- /Resources/date_picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/Resources/date_picker.png -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Tachyon/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNDecorationViewLayoutAttributes.m: -------------------------------------------------------------------------------- 1 | #import "TCNDecorationViewLayoutAttributes.h" 2 | 3 | @implementation TCNDecorationViewLayoutAttributes 4 | 5 | @end 6 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2017 LinkedIn Corporation 2 | All Rights Reserved. 3 | 4 | Licensed under the BSD 2-Clause License (the "License"). 5 | See LICENSE in the project root for license information. 6 | -------------------------------------------------------------------------------- /TachyonSampleApp/Tachyon-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #ifndef Tachyon_Bridging_Header_h 2 | #define Tachyon_Bridging_Header_h 3 | 4 | #import "Tachyon.h" 5 | 6 | #endif /* Tachyon_Bridging_Header_h */ 7 | -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_16dp.imageset/ic_cancel_16dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/TachyonSampleApp/Assets.xcassets/ic_cancel_16dp.imageset/ic_cancel_16dp.png -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_24dp.imageset/ic_cancel_24dp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/TachyonSampleApp/Assets.xcassets/ic_cancel_24dp.imageset/ic_cancel_24dp.png -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_16dp.imageset/ic_cancel_16dp@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/TachyonSampleApp/Assets.xcassets/ic_cancel_16dp.imageset/ic_cancel_16dp@2x.png -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_16dp.imageset/ic_cancel_16dp@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/TachyonSampleApp/Assets.xcassets/ic_cancel_16dp.imageset/ic_cancel_16dp@3x.png -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_24dp.imageset/ic_cancel_24dp@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/TachyonSampleApp/Assets.xcassets/ic_cancel_24dp.imageset/ic_cancel_24dp@2x.png -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_24dp.imageset/ic_cancel_24dp@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkedInAttic/Tachyon-iOS/HEAD/TachyonSampleApp/Assets.xcassets/ic_cancel_24dp.imageset/ic_cancel_24dp@3x.png -------------------------------------------------------------------------------- /Tachyon/Views/TCNReusableView.h: -------------------------------------------------------------------------------- 1 | @protocol TCNReusableView 2 | 3 | /** 4 | The reuse identifier for this view. 5 | */ 6 | @property (nonatomic, copy, nonnull, class, readonly) NSString *reuseIdentifier; 7 | 8 | @end 9 | -------------------------------------------------------------------------------- /Tachyon/Public API/Tachyon.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "TCNDatePickerView.h" 4 | #import "TCNDateUtil.h" 5 | #import "TCNDatePickerConfig.h" 6 | #import "TCNDayView.h" 7 | #import "TCNDayViewConfig.h" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build Directory 2 | .DS_Store 3 | DerivedData 4 | output/* 5 | # User Data 6 | *.pbxuser 7 | xcuserdata 8 | xcuserstate 9 | profile 10 | !default.pbxuser 11 | # Workspace 12 | *.xcworkspace 13 | !default.xcworkspace 14 | *.moved-aside 15 | # CocoaPods 16 | Podfile.lock 17 | Pods 18 | # configs 19 | config/ 20 | # Other files to ignore 21 | 22 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNDateFormatter+Testing.h: -------------------------------------------------------------------------------- 1 | #import "TCNDateFormatter.h" 2 | 3 | @interface TCNDateFormatter (Testing) 4 | 5 | /** 6 | This is for TESTING ONLY, and does not return a localized date. 7 | Ex: Mon Jan 1, 2019 8 | */ 9 | @property (nonatomic, strong, nonnull, class, readonly) NSDateFormatter *testing_monthDayYearFormatter; 10 | 11 | @end 12 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '9.0' 2 | 3 | target 'Tachyon' do 4 | 5 | use_frameworks! 6 | 7 | target 'TachyonTests' do 8 | inherit! :search_paths 9 | 10 | pod 'LayoutTest', '4.0.2' 11 | end 12 | 13 | target 'TachyonUITests' do 14 | inherit! :search_paths 15 | end 16 | 17 | end 18 | 19 | target 'TachyonSampleApp' do 20 | 21 | use_frameworks! 22 | 23 | end 24 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNNumberHelper.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | /** 5 | Static helpers for number operations involving CGFloat, which differs between 32-bit and 64-bit architectures. 6 | */ 7 | @interface TCNNumberHelper : NSObject 8 | 9 | + (CGFloat)ceil:(CGFloat)value; 10 | 11 | + (CGFloat)floor:(CGFloat)value; 12 | 13 | + (CGFloat)mod:(CGFloat)valueOne by:(CGFloat)valueTwo; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNDecorationViewLayoutAttributes.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | /** 4 | A subclass of @c UICollectionViewLayoutAttributes that holds custom attributes for decoration views. 5 | */ 6 | @interface TCNDecorationViewLayoutAttributes : UICollectionViewLayoutAttributes 7 | 8 | /** 9 | The background color for a decoration view. 10 | */ 11 | @property (nonatomic, strong, nullable, readwrite) UIColor *backgroundColor; 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNDatePickerSelectionIndicatorView.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "TCNReusableView.h" 4 | 5 | /** 6 | A colored view indicating that a @c TCNDatePickerDayView is selected. 7 | */ 8 | @interface TCNDatePickerSelectionIndicatorView: UICollectionReusableView 9 | 10 | /** 11 | The reuse identifier for this collectionview view. 12 | */ 13 | @property (nonatomic, copy, nonnull, class, readonly) NSString *reuseIdentifier; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNDayViewLayout+Protected.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNDayViewLayout.h" 3 | 4 | @interface TCNDayViewLayout (Protected) 5 | 6 | /** 7 | Retrieves the target @c zIndex for an element of the given @c elementKind type. 8 | 9 | @param elementKind A string identifier corresponding to a collection view element type. 10 | @return An integer representing the zIndex. 11 | */ 12 | - (NSInteger)zIndexForElementKind:(nonnull NSString *)elementKind; 13 | 14 | @end 15 | -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_16dp.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "ic_cancel_16dp.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "ic_cancel_16dp@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "ic_cancel_16dp@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/ic_cancel_24dp.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "ic_cancel_24dp.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "ic_cancel_24dp@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "ic_cancel_24dp@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Tachyon/Views/TCNDatePickerDayView.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "TCNDatePickerConfig.h" 4 | #import "TCNReusableView.h" 5 | 6 | /** 7 | Represents a single day on a @c TCNDatePickerView instance. 8 | */ 9 | @interface TCNDatePickerDayView : UICollectionViewCell 10 | 11 | /** 12 | The date which this object represents. 13 | 14 | Setting this property automatically updates the collectionView's labels appropriately. 15 | */ 16 | @property (nonatomic, strong, nonnull, readwrite) NSDate *date; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNNumberHelper.m: -------------------------------------------------------------------------------- 1 | #import "TCNNumberHelper.h" 2 | 3 | @implementation TCNNumberHelper 4 | 5 | + (CGFloat)ceil:(CGFloat)value { 6 | #ifdef __LP64__ 7 | return ceil(value); 8 | #else 9 | return ceilf(value); 10 | #endif 11 | } 12 | 13 | + (CGFloat)floor:(CGFloat)value { 14 | #ifdef __LP64__ 15 | return floor(value); 16 | #else 17 | return floorf(value); 18 | #endif 19 | } 20 | 21 | + (CGFloat)mod:(CGFloat)valueOne by:(CGFloat)valueTwo { 22 | #ifdef __LP64__ 23 | return fmod(valueOne, valueTwo); 24 | #else 25 | return fmodf(valueOne, valueTwo); 26 | #endif 27 | } 28 | 29 | @end 30 | -------------------------------------------------------------------------------- /Tachyon.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'Tachyon' 3 | spec.version = '0.0.23' 4 | spec.requires_arc = true 5 | spec.platform = :ios, '10.0' 6 | spec.summary = 'Tachyon provides configurable UI components commonly used in calendar features and applications.' 7 | spec.source = { :git => 'https://github.com/linkedin/Tachyon-iOS.git', :tag => spec.version } 8 | spec.homepage = "https://github.com/linkedin/Tachyon-iOS" 9 | spec.license = '2-clause BSD' 10 | spec.source_files = 'Tachyon/**/*.{swift,h,m}' 11 | spec.authors = 'LinkedIn' 12 | spec.ios.frameworks = 'Foundation', 'UIKit' 13 | end 14 | -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNAllDayViewLayout.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNDayViewLayout.h" 3 | 4 | /** 5 | The default collection view layout for an all day event collection view. 6 | 7 | For the layout used in the standard event collection view, refer to @c TCNDayViewLayout. 8 | */ 9 | @interface TCNAllDayViewLayout : TCNDayViewLayout 10 | 11 | /** 12 | Asks for the required height for an all day view, given @c eventCount. 13 | 14 | @param eventCount The number of events. 15 | @return A floating-point height for the all day view. 16 | */ 17 | + (CGFloat)allDayViewHeightForEventCount:(NSInteger)eventCount; 18 | 19 | @end 20 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNViewUtils.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | @interface TCNViewUtils : NSObject 5 | 6 | /** 7 | @return @c YES if the @c UIApplication layout direction is right to left. 8 | 9 | @note This should only be called on the main thread. 10 | */ 11 | + (BOOL)isLayoutDirectionRTL; 12 | 13 | /** 14 | Recursively lays out this view and its subviews for right-to-left support. 15 | 16 | If the application's layout direction is not RTL, this is a no-op. 17 | 18 | @param view The view to lay out with RTL support. 19 | */ 20 | + (void)layoutSubviewsForRTL:(nonnull UIView *)view; 21 | 22 | @end 23 | -------------------------------------------------------------------------------- /TachyonTests/Day View/TCNDayViewTestsViewProvider.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "TCNDayView.h" 4 | 5 | @class TCNEvent; 6 | @protocol LYTViewProvider; 7 | 8 | /** 9 | This class implements LYTViewProvider and TCNDayViewDataSource, and is responsible for providing views compatible with 10 | the LayoutTest framework. 11 | */ 12 | @interface TCNDayViewTestsViewProvider : NSObject 13 | 14 | @property (nonatomic, strong, class, nonnull, readonly) TCNDayViewTestsViewProvider *sharedInstance; 15 | @property (nonatomic, strong, nonnull, readwrite) NSArray *events; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNViewUtils.m: -------------------------------------------------------------------------------- 1 | #import "TCNViewUtils.h" 2 | 3 | @implementation TCNViewUtils 4 | 5 | + (BOOL)isLayoutDirectionRTL { 6 | return UIApplication.sharedApplication.userInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft; 7 | } 8 | 9 | + (void)layoutSubviewsForRTL:(nonnull UIView *)view { 10 | if (![self isLayoutDirectionRTL]) { 11 | return; 12 | } 13 | 14 | for (UIView *const subview in view.subviews) { 15 | CGRect subviewRTLFrame = subview.frame; 16 | subviewRTLFrame.origin.x = CGRectGetWidth(view.frame) - CGRectGetMaxX(subview.frame); 17 | subview.frame = subviewRTLFrame; 18 | [self layoutSubviewsForRTL:subview]; 19 | } 20 | } 21 | 22 | @end 23 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNDayViewGridlineView.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "TCNReusableView.h" 4 | 5 | /** 6 | A simple divider view, used in @c TCNDayView collection views for calendar lines. 7 | */ 8 | @interface TCNDayViewGridlineView : UICollectionReusableView 9 | 10 | /** 11 | The reuse identifier for this decoration view. 12 | */ 13 | @property (nonatomic, copy, nonnull, class, readonly) NSString *reuseIdentifier; 14 | 15 | /** 16 | A string identifier for the dark gridline. 17 | */ 18 | @property (nonatomic, copy, nonnull, class, readonly) NSString *darkKind; 19 | 20 | /** 21 | A string identifier for the light gridline. 22 | */ 23 | @property (nonatomic, copy, nonnull, class, readonly) NSString *lightKind; 24 | 25 | @end 26 | -------------------------------------------------------------------------------- /TachyonTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /TachyonUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNEventCell.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNEvent.h" 3 | #import "TCNDayViewConfig.h" 4 | 5 | /** 6 | Represents a @c TCNEvent on a @c TCNDayView. 7 | */ 8 | @interface TCNEventCell : UICollectionViewCell 9 | 10 | /** 11 | The reuse identifier for this cell type. 12 | */ 13 | @property (nonatomic, copy, nonnull, class, readonly) NSString *reuseIdentifier; 14 | 15 | /** 16 | A code block that is called when the cancel "x" button is tapped on an event cell. 17 | */ 18 | @property (nonatomic, copy, nullable, readwrite) void(^cancelHandler)(void); 19 | 20 | /** 21 | Populates the cell with the given @c TCNEvent. 22 | 23 | @param event A @c TCNEvent instance. 24 | */ 25 | - (void)updateWithEvent:(nonnull TCNEvent *)event; 26 | 27 | @end 28 | -------------------------------------------------------------------------------- /TachyonTests/Test Utilities/TCNTestUtils.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface TCNTestUtils : NSObject 4 | 5 | /** 6 | @param timeString An hour and minute of the form HH:mm, in 24-hour time. 7 | @param day The base @c NSDate on which you wish to set the time. 8 | */ 9 | + (nonnull NSDate *)dateWithTime:(nonnull NSString *)timeString onDay:(nonnull NSDate *)day; 10 | 11 | /** 12 | @param timeString An hour and minute of the form HH:mm, in 24-hour time. 13 | @param day The base @c NSDate on which you wish to set the time. 14 | @param daysToAdd An integer adjustment representing the number of days you wish to add to @c day. 15 | */ 16 | + (nonnull NSDate *)dateWithTime:(nonnull NSString *)timeString onDay:(nonnull NSDate *)day daysToAdd:(NSInteger)daysToAdd; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNDayViewTimeView.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNDayViewConfig.h" 3 | #import "TCNReusableView.h" 4 | 5 | /** 6 | Displays a label denoting the hour or time of an associated time slot on a @c TCNDayView. 7 | */ 8 | @interface TCNDayViewTimeView : UICollectionReusableView 9 | 10 | /** 11 | The reuse identifier for this cell type. 12 | */ 13 | @property (nonatomic, copy, nonnull, class, readonly) NSString *reuseIdentifier; 14 | 15 | /** 16 | Setting this property automatically updates the label indicating the time appropriately. 17 | */ 18 | @property (nonatomic, strong, nonnull, readwrite) NSDate *time; 19 | 20 | /** 21 | Updates the label to the all day event text specified by the parent day view's @c TCNDayViewConfig. 22 | */ 23 | - (void)updateAllDayEventText; 24 | 25 | @end 26 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDatePickerConfig.m: -------------------------------------------------------------------------------- 1 | #import "TCNDatePickerConfig.h" 2 | #import "TCNMacros.h" 3 | 4 | @implementation TCNDatePickerConfig 5 | 6 | - (nonnull instancetype)init { 7 | self = [super init]; 8 | if (!self) { 9 | return nil; 10 | } 11 | 12 | // default implementation 13 | 14 | _backgroundColor = [UIColor whiteColor]; 15 | _selectedColor = [UIColor redColor]; 16 | _primaryFont = [UIFont systemFontOfSize:16.0f]; 17 | _secondaryFont = [UIFont systemFontOfSize:12.0f weight:UIFontWeightMedium]; 18 | _monthLabelFont = [UIFont systemFontOfSize:16.0f]; 19 | _textColor = [UIColor blackColor]; 20 | _selectedTextColor = [UIColor whiteColor]; 21 | _weekendTextColor = [UIColor blackColor]; 22 | _datePickerBackgroundProvider = nil; 23 | _customDatePickerViewConfig = nil; 24 | 25 | return self; 26 | } 27 | 28 | @end 29 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNDateFormatter.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | /** 4 | Provides convenience @c NSDateFormatter instances. 5 | */ 6 | @interface TCNDateFormatter : NSObject 7 | 8 | /** 9 | Ex: M/T/W 10 | */ 11 | @property (nonatomic, strong, nonnull, class, readonly) NSDateFormatter *dayOfWeekFormatter; 12 | 13 | /** 14 | Ex: 1 15 | */ 16 | @property (nonatomic, strong, nonnull, class, readonly) NSDateFormatter *dayOfMonthFormatter; 17 | 18 | /** 19 | Ex: January, 2019 20 | */ 21 | @property (nonatomic, strong, nonnull, class, readonly) NSDateFormatter *monthAndYearFormatter; 22 | 23 | /** 24 | Ex: 1 PM 25 | */ 26 | @property (nonatomic, strong, nonnull, class, readonly) NSDateFormatter *sidebarTimeFormatter; 27 | 28 | /** 29 | Ex: 1:00 PM 30 | */ 31 | @property (nonatomic, strong, nonnull, class, readonly) NSDateFormatter *timeFormatter; 32 | 33 | - (nonnull instancetype)init NS_UNAVAILABLE; 34 | 35 | @end 36 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNDayViewGridlineView.m: -------------------------------------------------------------------------------- 1 | #import "TCNDayViewGridlineView.h" 2 | #import "TCNDecorationViewLayoutAttributes.h" 3 | #import "TCNMacros.h" 4 | 5 | @implementation TCNDayViewGridlineView 6 | 7 | + (nonnull NSString *)reuseIdentifier { 8 | return NSStringFromClass([TCNDayViewGridlineView class]); 9 | } 10 | 11 | + (nonnull NSString *)darkKind { 12 | return @"TCNDayViewGridlineViewDarkKind"; 13 | } 14 | 15 | + (nonnull NSString *)lightKind { 16 | return @"TCNDayViewGridlineViewLightKind"; 17 | } 18 | 19 | - (void)applyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes { 20 | [super applyLayoutAttributes:layoutAttributes]; 21 | 22 | TCNDecorationViewLayoutAttributes *const attributes = TCN_CAST_OR_NIL(layoutAttributes, TCNDecorationViewLayoutAttributes); 23 | if (!attributes) { 24 | return; 25 | } 26 | self.backgroundColor = attributes.backgroundColor; 27 | } 28 | 29 | @end 30 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNMacros.h: -------------------------------------------------------------------------------- 1 | // conditional casting if object corresponds to ClassType 2 | // TCN_CAST_OR_NIL([userInfo valueForKey:kLINETSessionControllerEventType], NSString) 3 | #define TCN_CAST_OR_NIL(Object, ClassType) \ 4 | ({ \ 5 | id obj = Object; \ 6 | bool isKind = [obj isKindOfClass:[ClassType class]]; \ 7 | isKind ? (ClassType *)obj : nil; \ 8 | }) 9 | 10 | // macro for casting a nullable variable to its nonnull equivalent, once its nil-ness has already been checked 11 | #define TCN_FORCE_UNWRAP(Variable) ({ __auto_type _Nonnull nonnull_Variable = Variable; nonnull_Variable; }) 12 | 13 | // logs an error and asserts if in debug mode 14 | #ifdef DEBUG 15 | #define TCN_ASSERT_FAILURE(FORMAT, ...) \ 16 | raise(SIGABRT); \ 17 | NSLog(FORMAT, ##__VA_ARGS__); 18 | #else 19 | #define TCN_ASSERT_FAILURE(FORMAT, ...) \ 20 | NSLog(FORMAT, ##__VA_ARGS__); 21 | #endif 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Agreement 2 | As a contributor, you represent that the code you submit is your original work or that of your employer (in which case you represent you have the right to bind your employer). By submitting code, you (and, if applicable, your employer) are licensing the submitted code to LinkedIn and the open source community subject to the BSD 2-Clause license. 3 | 4 | # Responsible Disclosure of Security Vulnerabilities 5 | Please do not file reports on Github for security issues. Please review the guidelines on at (link to more info). Reports should be encrypted using PGP (link to PGP key) and sent to security@linkedin.com preferably with the title "Github linkedin/ - ". 6 | 7 | # Tips for Getting Your Pull Request Accepted 8 | 1. Avoid scope creep. This is not intended to be a full feature calendar application with all its bells and whistles. 9 | 2. Make sure all new code additions include unit testing, if applicable. 10 | 3. Open an issue first and seek advice for your change before submitting a pull request. Large features which have never been discussed are unlikely to be accepted. 11 | -------------------------------------------------------------------------------- /TachyonSampleApp/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-CLAUSE LICENSE 2 | 3 | Copyright 2019 LinkedIn Corporation. 4 | All Rights Reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the 16 | distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNDatePickerLayout.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNDatePickerConfig.h" 3 | 4 | #pragma mark - TCNDatePickerLayoutDelegate 5 | 6 | /** 7 | Classes implementing this protocol are responsible for providing information about the currently selected date to @c TCNDatePickerLayout. 8 | */ 9 | @protocol TCNDatePickerLayoutDelegate 10 | 11 | /** 12 | The index path of the currently selected date, if any. 13 | */ 14 | @property (nonatomic, strong, nullable, readonly) NSIndexPath *selectedDateIndexPath; 15 | 16 | @end 17 | 18 | #pragma mark - TCNDatePickerLayout 19 | 20 | /** 21 | The default implementation for collection view layout in @c TCNDatePickerView. 22 | 23 | This class also provides caching for layout attributes. 24 | */ 25 | @interface TCNDatePickerLayout : UICollectionViewFlowLayout 26 | 27 | /** 28 | The delegate for this @c TCNDatePickerLayout. 29 | */ 30 | @property (nonatomic, weak, nullable, readwrite) id delegate; 31 | 32 | /** 33 | A new date picker layout with the specified @c TCNDatePickerConfig. 34 | 35 | @param config A configuration object. 36 | @return A @c TCNDatePickerLayout instance. 37 | */ 38 | - (nonnull instancetype)initWithConfig:(nonnull TCNDatePickerConfig *)config NS_DESIGNATED_INITIALIZER; 39 | 40 | - (nonnull instancetype)initWithCoder:(nonnull NSCoder *)aDecoder NS_UNAVAILABLE; 41 | 42 | - (nonnull instancetype)init NS_UNAVAILABLE; 43 | 44 | @end 45 | -------------------------------------------------------------------------------- /TachyonTests/Test Utilities/TCNTestUtils.m: -------------------------------------------------------------------------------- 1 | #import "TCNTestUtils.h" 2 | #import "TCNDateUtil.h" 3 | 4 | @implementation TCNTestUtils 5 | 6 | + (nonnull NSDateFormatter *)dateFormatter { 7 | static NSDateFormatter *_dateFormatter; 8 | static dispatch_once_t onceToken; 9 | dispatch_once(&onceToken, ^{ 10 | _dateFormatter = [[NSDateFormatter alloc] init]; 11 | _dateFormatter.dateFormat = @"YYYY-MM-dd HH:mm"; 12 | }); 13 | return _dateFormatter; 14 | } 15 | 16 | + (nonnull NSDate *)dateWithTime:(nonnull NSString *)timeString onDay:(nonnull NSDate *)day { 17 | return [self dateWithTime:timeString onDay:day daysToAdd:0]; 18 | } 19 | 20 | + (nonnull NSDate *)dateWithTime:(nonnull NSString *)timeString onDay:(nonnull NSDate *)day daysToAdd:(NSInteger)daysToAdd { 21 | NSDate *const time = [self.dateFormatter dateFromString:[NSString stringWithFormat:@"2019-01-18 %@", timeString]]; 22 | NSCalendar *calendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; 23 | NSDateComponents *timeComponents = [calendar componentsInTimeZone:[NSTimeZone localTimeZone] fromDate:time]; 24 | NSDate *dayToUse = [TCNDateUtil dateByAddingDays:daysToAdd toDate:day]; 25 | 26 | return [calendar dateBySettingHour:timeComponents.hour 27 | minute:timeComponents.minute 28 | second:timeComponents.second 29 | ofDate:dayToUse 30 | options:0]; 31 | } 32 | 33 | @end 34 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNDatePickerSelectionIndicatorView.m: -------------------------------------------------------------------------------- 1 | #import "TCNDatePickerSelectionIndicatorView.h" 2 | #import "TCNDecorationViewLayoutAttributes.h" 3 | #import "TCNMacros.h" 4 | 5 | @interface TCNDatePickerSelectionIndicatorView () 6 | 7 | @property (nonatomic, strong, nonnull) CAShapeLayer *roundedCornerMask; 8 | 9 | @end 10 | 11 | @implementation TCNDatePickerSelectionIndicatorView 12 | 13 | static const int CornerRadius = 2; 14 | 15 | + (nonnull NSString *)reuseIdentifier { 16 | return NSStringFromClass([TCNDatePickerSelectionIndicatorView class]); 17 | } 18 | 19 | - (nonnull instancetype)initWithFrame:(CGRect)frame { 20 | self = [super initWithFrame:frame]; 21 | if (!self) { 22 | return nil; 23 | } 24 | _roundedCornerMask = [CAShapeLayer layer]; 25 | return self; 26 | } 27 | 28 | - (void)layoutSubviews { 29 | [super layoutSubviews]; 30 | 31 | UIBezierPath *const path = [UIBezierPath bezierPathWithRoundedRect:self.bounds cornerRadius:CornerRadius]; 32 | self.roundedCornerMask.path = path.CGPath; 33 | } 34 | 35 | - (void)willMoveToSuperview:(UIView *)newSuperview { 36 | [super willMoveToSuperview:newSuperview]; 37 | 38 | self.layer.mask = self.roundedCornerMask; 39 | } 40 | 41 | - (void)applyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes { 42 | [super applyLayoutAttributes:layoutAttributes]; 43 | 44 | TCNDecorationViewLayoutAttributes *const attributes = TCN_CAST_OR_NIL(layoutAttributes, TCNDecorationViewLayoutAttributes); 45 | if (!attributes) { 46 | return; 47 | } 48 | self.backgroundColor = attributes.backgroundColor; 49 | } 50 | 51 | @end 52 | -------------------------------------------------------------------------------- /TachyonSampleApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDayViewConfig.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNDayViewConfig.h" 3 | 4 | @implementation TCNDayViewConfig 5 | 6 | static NSString *const DefaultNewEventText = @"Available"; 7 | static NSString *const DefaultAllDayEventText = @"All Day"; 8 | 9 | - (nonnull instancetype)init { 10 | self = [super init]; 11 | if (!self) { 12 | return nil; 13 | } 14 | 15 | _createdEventText = DefaultNewEventText; 16 | _allDayLabelText = DefaultAllDayEventText; 17 | _defaultEventLength = TCNEventLengthHalfHour; 18 | _backgroundColor = [UIColor whiteColor]; 19 | 20 | _eventFont = [UIFont systemFontOfSize:12.0f]; 21 | _eventTextColor = [UIColor blackColor]; 22 | _eventColor = [UIColor colorWithRed:225.0f / 255.0f 23 | green:223.0f / 255.0f 24 | blue:238.0f / 255.0f 25 | alpha:1.0f]; 26 | 27 | _timeslotFont = [UIFont systemFontOfSize:12.0f]; 28 | _timeslotTextColor = [UIColor blueColor]; 29 | _timeslotColor = [UIColor greenColor]; 30 | 31 | _sidebarFont = [UIFont systemFontOfSize:12.0f]; 32 | _sidebarTextColor = [UIColor blackColor]; 33 | _sidebarColor = [UIColor whiteColor]; 34 | 35 | _gridlineDarkColor = [UIColor darkGrayColor]; 36 | _gridlineLightColor = [UIColor lightGrayColor]; 37 | 38 | _shouldShowCancelButtonOnCreatedEvents = YES; 39 | 40 | _dayViewBackgroundProvider = nil; 41 | _allDayViewBackgroundProvider = nil; 42 | _customDayViewConfig = nil; 43 | _customDayViewConfig = nil; 44 | _cancelButtonImage = nil; 45 | 46 | return self; 47 | } 48 | 49 | @end 50 | -------------------------------------------------------------------------------- /Tachyon/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /TachyonSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /TachyonTests/Day View/TCNDayViewTestsViewProvider.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | #import "TCNDayViewTestsViewProvider.h" 5 | #import "TCNEvent.h" 6 | 7 | @implementation TCNDayViewTestsViewProvider 8 | 9 | + (instancetype)sharedInstance { 10 | static TCNDayViewTestsViewProvider *_provider; 11 | static dispatch_once_t onceToken; 12 | dispatch_once(&onceToken, ^{ 13 | _provider = [[self alloc] init]; 14 | }); 15 | return _provider; 16 | } 17 | 18 | - (instancetype)init { 19 | self = [super init]; 20 | if (!self) { 21 | return nil; 22 | } 23 | 24 | _events = @[]; 25 | return self; 26 | } 27 | 28 | #pragma mark - TCNDayViewDataSource 29 | 30 | - (nonnull NSDate *)currentDate { 31 | if (self.events.firstObject) { 32 | return self.events.firstObject.startDateTime; 33 | } 34 | return [NSDate date]; 35 | } 36 | 37 | - (nonnull NSArray *)dayEvents { 38 | if (!self.events) { 39 | return @[]; 40 | } 41 | NSPredicate *predicate = [NSPredicate predicateWithFormat:@"isAllDay == FALSE"]; 42 | return [self.events filteredArrayUsingPredicate:predicate]; 43 | } 44 | 45 | - (nonnull NSArray *)allDayEvents { 46 | if (!self.events) { 47 | return @[]; 48 | } 49 | NSPredicate *predicate = [NSPredicate predicateWithFormat:@"isAllDay == TRUE"]; 50 | return [self.events filteredArrayUsingPredicate:predicate]; 51 | } 52 | 53 | #pragma mark - LYTViewProvider 54 | 55 | + (NSDictionary *)dataSpecForTest { 56 | return @{}; 57 | } 58 | 59 | + (UIView *)viewForData:(NSDictionary *)data reuseView:(UIView *)reuseView size:(LYTViewSize *)size context:(id _Nullable __autoreleasing *)context { 60 | TCNDayView *const dayView = [[TCNDayView alloc] initWithFrame:UIScreen.mainScreen.bounds]; 61 | dayView.dataSource = self.sharedInstance; 62 | 63 | // We need to call willMoveToSuperview to set the delegates and dataSources 64 | [dayView willMoveToSuperview:[[UIView alloc] init]]; 65 | 66 | return dayView; 67 | } 68 | 69 | @end 70 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDatePickerView.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNDatePickerConfig.h" 3 | 4 | @class TCNDatePickerView; 5 | 6 | /** 7 | Classes may implement this protocol to receive updates from a @c TCNDatePickerView instance when a date is selected. 8 | */ 9 | @protocol TCNDatePickerDelegate 10 | 11 | /** 12 | Called when a date is selected. 13 | */ 14 | - (void)datePickerView:(nonnull TCNDatePickerView *)datePickerView didSelectDate:(nonnull NSDate *)date; 15 | 16 | @end 17 | 18 | /** 19 | A paging view that displays the month and date, and allows users to select a date. 20 | */ 21 | @interface TCNDatePickerView : UIView 22 | 23 | /** 24 | Delegate object for the date picker. 25 | */ 26 | @property (nonatomic, weak, nullable, readwrite) id datePickerDelegate; 27 | 28 | /** 29 | Returns the currently selected date. 30 | */ 31 | @property (nonatomic, strong, nonnull, readonly) NSDate *selectedDate; 32 | 33 | /** 34 | Requests the height required by the date picker given @c config. The date picker should have its height 35 | set to this value to ensure correct layout. 36 | 37 | @param config A config object for which to calculate a height. 38 | @return A floating point height value for the picker. 39 | */ 40 | + (CGFloat)heightRequiredForConfig:(nonnull TCNDatePickerConfig *)config; 41 | 42 | /** 43 | Creates an instance of the date picker using the given config. 44 | 45 | @param frame The initial frame of the view. 46 | @param config The configuration object for the view. 47 | @return A @c TCNDatePickerView instance. 48 | */ 49 | - (nonnull instancetype)initWithFrame:(CGRect)frame config:(nonnull TCNDatePickerConfig *)config NS_DESIGNATED_INITIALIZER; 50 | 51 | - (nonnull instancetype)initWithCoder:(nonnull NSCoder *)aDecoder NS_UNAVAILABLE; 52 | 53 | /** 54 | Sets a new date to be selected by passing in an @c NSDate object. This should be called after the date picker object has been added as a subview. 55 | 56 | @param date The date to select. 57 | @param animated If YES, the picker will animate to its new selection. 58 | */ 59 | - (void)selectDate:(nonnull NSDate *)date animated:(BOOL)animated; 60 | 61 | @end 62 | -------------------------------------------------------------------------------- /TachyonSampleApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 9 | window = UIWindow(frame: UIScreen.main.bounds) 10 | window?.rootViewController = ViewController() 11 | window?.makeKeyAndVisible() 12 | return true 13 | } 14 | 15 | func applicationWillResignActive(_ application: UIApplication) { 16 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 17 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 18 | } 19 | 20 | func applicationDidEnterBackground(_ application: UIApplication) { 21 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 22 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 23 | } 24 | 25 | func applicationWillEnterForeground(_ application: UIApplication) { 26 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. 27 | } 28 | 29 | func applicationDidBecomeActive(_ application: UIApplication) { 30 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 31 | } 32 | 33 | func applicationWillTerminate(_ application: UIApplication) { 34 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNDayViewTimeView.m: -------------------------------------------------------------------------------- 1 | #import "TCNDayViewTimeView.h" 2 | #import "TCNDateFormatter.h" 3 | 4 | @interface TCNDayViewTimeView () 5 | 6 | @property (nonatomic, strong, nonnull) UILabel *titleLabel; 7 | @property (nonatomic, copy, nullable) NSString *allDayText; 8 | 9 | @end 10 | 11 | @implementation TCNDayViewTimeView 12 | 13 | static const UIEdgeInsets Padding = {0.0f, 10.0f, 0.0f, 10.0f}; 14 | 15 | + (nonnull NSString *)reuseIdentifier { 16 | return NSStringFromClass([TCNDayViewTimeView class]); 17 | } 18 | 19 | #pragma mark - Initialization 20 | 21 | - (nonnull instancetype)initWithFrame:(CGRect)frame { 22 | self = [super initWithFrame:frame]; 23 | if (!self) { 24 | return nil; 25 | } 26 | 27 | _titleLabel = [TCNDayViewTimeView labelWithSuperview:self]; 28 | _time = [NSDate date]; 29 | return self; 30 | } 31 | 32 | #pragma mark - Class helpers 33 | 34 | + (nonnull UILabel *)labelWithSuperview:(nonnull UIView *)superview { 35 | UILabel *const label = [[UILabel alloc] init]; 36 | label.adjustsFontSizeToFitWidth = YES; 37 | label.textAlignment = NSTextAlignmentRight; 38 | [superview addSubview:label]; 39 | return label; 40 | } 41 | 42 | #pragma mark - View lifecycle 43 | 44 | - (void)layoutSubviews { 45 | [super layoutSubviews]; 46 | 47 | CGRect bounds = self.bounds; 48 | CGRect titleFrame = CGRectMake(Padding.left, 0, bounds.size.width - Padding.right - Padding.left, bounds.size.height); 49 | self.titleLabel.frame = titleFrame; 50 | } 51 | 52 | - (void)prepareForReuse { 53 | [super prepareForReuse]; 54 | self.titleLabel.text = @""; 55 | } 56 | 57 | - (void)setTime:(nonnull NSDate *)time { 58 | _time = time; 59 | 60 | self.titleLabel.text = [TCNDateFormatter.sidebarTimeFormatter stringFromDate:time]; 61 | } 62 | 63 | - (void)updateAllDayEventText { 64 | self.titleLabel.text = self.allDayText; 65 | } 66 | 67 | # pragma mark - TCNDayViewConfigurable 68 | 69 | - (void)applyStylingFromConfig:(TCNDayViewConfig *)config selected:(__unused BOOL)selected { 70 | self.backgroundColor = config.sidebarColor; 71 | self.titleLabel.textColor = config.sidebarTextColor; 72 | self.titleLabel.font = config.sidebarFont; 73 | self.allDayText = config.allDayLabelText; 74 | } 75 | 76 | @end 77 | -------------------------------------------------------------------------------- /Tachyon.xcodeproj/xcshareddata/xcschemes/TachyonUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 17 | 18 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 42 | 43 | 44 | 47 | 48 | 49 | 55 | 56 | 58 | 59 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /Tachyon.xcodeproj/xcshareddata/xcschemes/TachyonTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 17 | 18 | 24 | 25 | 26 | 27 | 29 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 51 | 52 | 53 | 54 | 60 | 61 | 63 | 64 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNDatePickerDayView.m: -------------------------------------------------------------------------------- 1 | #import "TCNDatePickerDayView.h" 2 | #import "TCNDateFormatter.h" 3 | #import "TCNMacros.h" 4 | 5 | @interface TCNDatePickerDayView () 6 | 7 | @property (nonatomic, strong, nonnull) UILabel *dayOfWeekLabel; 8 | @property (nonatomic, strong, nonnull) UILabel *dateLabel; 9 | 10 | @end 11 | 12 | @implementation TCNDatePickerDayView 13 | 14 | #pragma mark - Properties 15 | 16 | + (nonnull NSString *)reuseIdentifier { 17 | return NSStringFromClass([TCNDatePickerDayView class]); 18 | } 19 | 20 | #pragma mark - Initialization 21 | 22 | - (nonnull instancetype)initWithFrame:(CGRect)frame { 23 | self = [super initWithFrame:frame]; 24 | if (!self) { 25 | return nil; 26 | } 27 | 28 | _dayOfWeekLabel = [TCNDatePickerDayView labelWithDayView:self]; 29 | _dateLabel = [TCNDatePickerDayView labelWithDayView:self]; 30 | 31 | return self; 32 | } 33 | 34 | #pragma mark - Class methods 35 | 36 | + (nonnull UILabel *)labelWithDayView:(nonnull TCNDatePickerDayView *)dayView { 37 | UILabel *const label = [[UILabel alloc] init]; 38 | label.textAlignment = NSTextAlignmentCenter; 39 | [dayView.contentView addSubview:label]; 40 | return label; 41 | } 42 | 43 | #pragma mark - View lifecycle 44 | 45 | - (void)prepareForReuse { 46 | [super prepareForReuse]; 47 | 48 | self.dayOfWeekLabel.text = @""; 49 | self.dateLabel.text = @""; 50 | } 51 | 52 | - (void)layoutSubviews { 53 | [super layoutSubviews]; 54 | CGFloat width = self.bounds.size.width; 55 | CGFloat height = self.bounds.size.height; 56 | 57 | self.dayOfWeekLabel.frame = CGRectMake(0, 4, width, 15); 58 | 59 | CGFloat dayOfWeekBottom = self.dayOfWeekLabel.frame.size.height + self.dayOfWeekLabel.frame.origin.y; 60 | self.dateLabel.frame = CGRectMake(0, dayOfWeekBottom, width, height - dayOfWeekBottom); 61 | } 62 | 63 | - (void)setDate:(NSDate *)date { 64 | _date = date; 65 | self.dayOfWeekLabel.text = [TCNDateFormatter.dayOfWeekFormatter stringFromDate:self.date]; 66 | 67 | NSString *const dateString = [TCNDateFormatter.dayOfMonthFormatter stringFromDate:self.date]; 68 | self.dateLabel.text = [NSString stringWithFormat:@"%@", dateString]; 69 | } 70 | 71 | # pragma mark - LIDatePickerConfigurable 72 | 73 | - (void)applyStylingFromConfig:(TCNDatePickerConfig *)config selected:(BOOL)selected { 74 | self.dayOfWeekLabel.font = config.secondaryFont; 75 | self.dateLabel.font = config.primaryFont; 76 | 77 | UIColor *const unselectedColor = [[NSCalendar currentCalendar] isDateInWeekend:self.date] ? config.weekendTextColor : config.textColor; 78 | 79 | self.dayOfWeekLabel.textColor = selected ? config.selectedTextColor : unselectedColor; 80 | self.dateLabel.textColor = selected ? config.selectedTextColor : unselectedColor; 81 | } 82 | 83 | @end 84 | -------------------------------------------------------------------------------- /TachyonTests/Day View/TCNEventCellTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | #import "TCNEventCell.h" 5 | 6 | 7 | @interface TCNEventCellTests : LYTLayoutTestCase 8 | 9 | @end 10 | 11 | @implementation TCNEventCellTests 12 | 13 | - (void)testUnselectedDisplay { 14 | [self runLayoutTestsWithViewProvider:[self class] validation:^(TCNEventCell *_Nonnull eventCell, NSDictionary * _Nonnull data, id _Nullable context) { 15 | [eventCell applyStylingFromConfig:[[TCNDayViewConfig alloc] init] selected:NO]; 16 | [eventCell updateWithEvent:[[TCNEvent alloc] initWithName:@"Test" startDateTime:[NSDate date]]]; 17 | [eventCell layoutSubviews]; 18 | }]; 19 | } 20 | 21 | - (void)testSelectedDisplay { 22 | [self runLayoutTestsWithViewProvider:[self class] validation:^(TCNEventCell *_Nonnull eventCell, NSDictionary * _Nonnull data, id _Nullable context) { 23 | [eventCell applyStylingFromConfig:[[TCNDayViewConfig alloc] init] selected:YES]; 24 | [eventCell updateWithEvent:[[TCNEvent alloc] initWithName:@"Test" startDateTime:[NSDate date]]]; 25 | [eventCell layoutSubviews]; 26 | 27 | [self ignoreAccessibilityCheckForEventCell:eventCell]; 28 | }]; 29 | } 30 | 31 | - (void)testCompactDisplay { 32 | [self runLayoutTestsWithViewProvider:[self class] validation:^(TCNEventCell *_Nonnull eventCell, NSDictionary * _Nonnull data, id _Nullable context) { 33 | TCNEvent *const event = [[TCNEvent alloc] initWithName:@"Test" 34 | startDateTime:[NSDate date] 35 | endDateTime:[NSDate date] 36 | location:nil 37 | timezone:nil 38 | isAllDay:YES]; 39 | [eventCell applyStylingFromConfig:[[TCNDayViewConfig alloc] init] selected:NO]; 40 | [eventCell updateWithEvent:event]; 41 | [eventCell layoutSubviews]; 42 | }]; 43 | } 44 | 45 | - (void)ignoreAccessibilityCheckForEventCell:(nonnull TCNEventCell *)eventCell { 46 | for (UIView *view in eventCell.contentView.subviews) { 47 | if ([view isKindOfClass:UIButton.self]) { 48 | [self.viewsAllowingAccessibilityErrors addObject:view]; 49 | } 50 | } 51 | } 52 | 53 | # pragma mark - LYTViewProvider 54 | 55 | + (NSDictionary *)dataSpecForTest { 56 | return @{}; 57 | } 58 | 59 | + (UIView *)viewForData:(NSDictionary *)data reuseView:(UIView *)reuseView size:(LYTViewSize *)size context:(id _Nullable __autoreleasing *)context { 60 | TCNEventCell *const eventCell = [[TCNEventCell alloc] initWithFrame:UIScreen.mainScreen.bounds]; 61 | 62 | return eventCell; 63 | } 64 | 65 | @end 66 | -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNDatePickerLayout.m: -------------------------------------------------------------------------------- 1 | #import "TCNDatePickerLayout.h" 2 | #import "TCNDecorationViewLayoutAttributes.h" 3 | #import "TCNMacros.h" 4 | #import "TCNDatePickerSelectionIndicatorView.h" 5 | 6 | @interface TCNDatePickerLayout () 7 | 8 | @property (nonatomic, strong, nonnull, readonly) TCNDatePickerConfig *config; 9 | @property (nonatomic, strong, nullable, readwrite) UICollectionViewLayoutAttributes *selectedItemAttributesCache; 10 | 11 | @end 12 | 13 | @implementation TCNDatePickerLayout 14 | 15 | - (instancetype)initWithConfig:(nonnull TCNDatePickerConfig *)config { 16 | self = [super init]; 17 | if (!self) { 18 | return nil; 19 | } 20 | 21 | _config = config; 22 | _selectedItemAttributesCache = nil; 23 | 24 | return self; 25 | } 26 | 27 | - (void)prepareLayout { 28 | [super prepareLayout]; 29 | [self invalidateLayoutCache]; 30 | 31 | [self prepareSelectedDayDecorationViewAttributes]; 32 | } 33 | 34 | - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect { 35 | NSArray *const baseLayoutAttributes = [super layoutAttributesForElementsInRect:rect]; 36 | if (!baseLayoutAttributes) { 37 | return nil; 38 | } 39 | 40 | if (self.selectedItemAttributesCache && CGRectIntersectsRect(self.selectedItemAttributesCache.frame, rect)) { 41 | UICollectionViewLayoutAttributes *const decorationLayoutAttributes = self.selectedItemAttributesCache; 42 | return [baseLayoutAttributes arrayByAddingObject:decorationLayoutAttributes]; 43 | } 44 | 45 | return baseLayoutAttributes; 46 | } 47 | 48 | - (void)prepareSelectedDayDecorationViewAttributes { 49 | NSIndexPath *const indexPath = [self.delegate selectedDateIndexPath]; 50 | if (!indexPath) { 51 | return; 52 | } 53 | 54 | TCNDecorationViewLayoutAttributes *const decorationAttributes = 55 | [TCNDecorationViewLayoutAttributes layoutAttributesForDecorationViewOfKind:TCNDatePickerSelectionIndicatorView.reuseIdentifier 56 | withIndexPath:indexPath]; 57 | decorationAttributes.frame = CGRectZero; 58 | UICollectionViewLayoutAttributes *const itemAttributes = [self layoutAttributesForItemAtIndexPath:indexPath]; 59 | if (itemAttributes) { 60 | decorationAttributes.frame = itemAttributes.frame; 61 | } 62 | // the selection decoration view should always appear under the cell, so the zIndex should be lower than corresponding cell's zIndex 63 | decorationAttributes.zIndex = itemAttributes.zIndex - 1; 64 | decorationAttributes.backgroundColor = self.config.selectedColor; 65 | 66 | self.selectedItemAttributesCache = decorationAttributes; 67 | } 68 | 69 | - (void)invalidateLayoutCache { 70 | self.selectedItemAttributesCache = nil; 71 | } 72 | 73 | @end 74 | -------------------------------------------------------------------------------- /Tachyon/DataSources/TCNDatePickerDataSource.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import "TCNDatePickerConfig.h" 4 | 5 | /** 6 | The default data source for @c TCNDatePickerView. 7 | 8 | This class handles logic for paging between weeks and dates. 9 | */ 10 | @interface TCNDatePickerDataSource : NSObject 11 | 12 | /** 13 | The days of the previous week. The previous week is the week chronologically prior 14 | to the currently visible week. 15 | */ 16 | @property (nonatomic, strong, nonnull, readonly) NSArray *previousWeekDates; 17 | 18 | /** 19 | The days of the current week. The current week is the currently visible week. 20 | */ 21 | @property (nonatomic, strong, nonnull, readonly) NSArray *activeWeekDates; 22 | 23 | /** 24 | The days of the next week. The next week is the week chronologically after 25 | the currently visible week. 26 | */ 27 | @property (nonatomic, strong, nonnull, readonly) NSArray *nextWeekDates; 28 | 29 | /** 30 | The currently selected date of the date picker. The date picker is initialized with the current date. 31 | */ 32 | @property (nonatomic, strong, nonnull, readwrite) NSDate *selectedDate; 33 | 34 | /** 35 | The date picker section index for the previous week. 36 | */ 37 | @property (nonatomic, assign, class, readonly) NSInteger datePickerSectionPreviousWeek; 38 | 39 | /** 40 | The date picker section index for the currently visible week. 41 | */ 42 | @property (nonatomic, assign, class, readonly) NSInteger datePickerSectionActiveWeek; 43 | 44 | /** 45 | The date picker section index for the next week. 46 | */ 47 | @property (nonatomic, assign, class, readonly) NSInteger datePickerSectionNextWeek; 48 | 49 | /** 50 | Sets up the data source's previous week, active week and next week dates given a reference date. 51 | The reference date will be in the active week's dates. 52 | 53 | @param date The reference date. 54 | */ 55 | - (void)setupWeekDatesWithCurrentlyVisibleDate:(nonnull NSDate *)date; 56 | 57 | /** 58 | Requests a date's @c indexPath relative to previous, active, or next weeks' dates. 59 | 60 | @param date The reference date. 61 | @return An index path for the given date. 62 | */ 63 | - (nullable NSIndexPath *)indexPathForDate:(nonnull NSDate *)date; 64 | 65 | /** 66 | Requests the date at the given @c indexPath. 67 | 68 | @param indexPath The @c NSIndexPath for which we want to find a corresponding date. 69 | @return A date for the given index path. 70 | */ 71 | - (nullable NSDate *)dateForItemAtIndexPath:(nonnull NSIndexPath *)indexPath; 72 | 73 | /** 74 | A new date picker data source with the specified @c config. 75 | 76 | @param config The configuration object for the parent @c TCNDatePickerView. 77 | @return A @c TCNDatePickerDataSource instance. 78 | */ 79 | - (nonnull instancetype)initWithConfig:(nonnull TCNDatePickerConfig *)config NS_DESIGNATED_INITIALIZER; 80 | 81 | - (nonnull instancetype)init NS_UNAVAILABLE; 82 | 83 | @end 84 | -------------------------------------------------------------------------------- /TachyonTests/Other/TCNDateUtilTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "TCNDateUtil.h" 4 | #import "TCNDateFormatter+Testing.h" 5 | 6 | @interface TCNDateUtilTests : XCTestCase 7 | 8 | @end 9 | 10 | /** 11 | This file must be run in en_US locale. 12 | */ 13 | @implementation TCNDateUtilTests 14 | 15 | /** 16 | If this fails, we are not in en_US locale. 17 | */ 18 | - (void)testLocale { 19 | XCTAssert([[NSLocale currentLocale].localeIdentifier isEqualToString:@"en_US"]); 20 | } 21 | 22 | - (void)testDateByAddingDays { 23 | NSDate *const today = [self dateFromString:@"Fri Jan 25, 2019"]; 24 | NSDate *fiveDaysFromToday = [TCNDateUtil dateByAddingDays:5 toDate:today]; 25 | 26 | XCTAssertTrue([[self stringFromDate:fiveDaysFromToday] isEqualToString:@"Wed, Jan 30, 2019"]); 27 | } 28 | 29 | - (void)testIndexOfDateInWeek { 30 | NSDate *const today = [self dateFromString:@"Fri Jan 25, 2019"]; 31 | NSInteger todayIndex = [TCNDateUtil indexOfDateInWeek:today]; 32 | XCTAssertEqual(todayIndex, 5); 33 | 34 | NSDate *const tomorrow = [self dateFromString:@"Sat Jan 26, 2019"]; 35 | NSInteger tomorrowIndex = [TCNDateUtil indexOfDateInWeek:tomorrow]; 36 | XCTAssertEqual(tomorrowIndex, 6); 37 | 38 | NSDate *const dayAfterTomorrow = [self dateFromString:@"Sat Jan 27, 2019"]; 39 | NSInteger dayAfterTomorrowIndex = [TCNDateUtil indexOfDateInWeek:dayAfterTomorrow]; 40 | XCTAssertEqual(dayAfterTomorrowIndex, 0); 41 | } 42 | 43 | - (void)testDaysOfWeekFromDate { 44 | NSDate *const today = [self dateFromString:@"Fri Jan 25, 2019"]; 45 | NSArray *const daysOfWeek = [TCNDateUtil daysOfWeekFromDate:today]; 46 | 47 | NSArray *const expectedDaysOfWeek = @[ 48 | @"Sun, Jan 20, 2019", 49 | @"Mon, Jan 21, 2019", 50 | @"Tue, Jan 22, 2019", 51 | @"Wed, Jan 23, 2019", 52 | @"Thu, Jan 24, 2019", 53 | @"Fri, Jan 25, 2019", 54 | @"Sat, Jan 26, 2019", 55 | ]; 56 | 57 | [daysOfWeek enumerateObjectsUsingBlock:^(NSDate * _Nonnull date, NSUInteger idx, __unused BOOL * _Nonnull stop) { 58 | NSString *dateString = [self stringFromDate:date]; 59 | XCTAssertTrue([dateString isEqualToString:expectedDaysOfWeek[idx]]); 60 | }]; 61 | } 62 | 63 | # pragma mark - Helpers 64 | 65 | - (nullable NSDate *)dateFromString:(nonnull NSString *)string { 66 | NSDateFormatter *const formatter = TCNDateFormatter.testing_monthDayYearFormatter; 67 | return [formatter dateFromString:string]; 68 | } 69 | 70 | - (nullable NSString *)stringFromDate:(nonnull NSDate *)date { 71 | NSDateFormatter *const formatter = TCNDateFormatter.testing_monthDayYearFormatter; 72 | return [formatter stringFromDate:date]; 73 | } 74 | 75 | @end 76 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDatePickerConfig.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | @class TCNDatePickerConfig; 5 | 6 | /** 7 | Classes implementing this protocol may adopt and use configuration parameters stored in @c TCNDatePickerConfig. 8 | */ 9 | @protocol TCNDatePickerConfigurable 10 | 11 | /** 12 | Any UI styling of the adopting class using properties stored in @c TCNDatePickerConfig should be done here. 13 | 14 | @param config The configuration object 15 | @param selected If true, the object is selected and should reference the corresponding selected values on the config object. 16 | */ 17 | - (void)applyStylingFromConfig:(nonnull TCNDatePickerConfig *)config selected:(BOOL)selected; 18 | 19 | @end 20 | 21 | /** 22 | The configuration object for @c TCNDatePickerView. This allows consumers of this view to 23 | modify certain UI properties. 24 | */ 25 | @interface TCNDatePickerConfig : NSObject 26 | 27 | /** 28 | The background color of a typical cell in the date picker. 29 | Defaults to white. 30 | */ 31 | @property (nonatomic, strong, nonnull, readwrite) UIColor *backgroundColor; 32 | 33 | /** 34 | The selected color of a cell when the cell is selected. 35 | Defaults to red. 36 | */ 37 | @property (nonatomic, strong, nonnull, readwrite) UIColor *selectedColor; 38 | 39 | /** 40 | The primary font, used for setting the date's label. 41 | Defaults to system font of size 16. 42 | */ 43 | @property (nonatomic, strong, nonnull, readwrite) UIFont *primaryFont; 44 | 45 | /** 46 | The secondary font, used for setting the day of the week's label. 47 | Defaults to system font of size 12 with medium weight. 48 | */ 49 | @property (nonatomic, strong, nonnull, readwrite) UIFont *secondaryFont; 50 | 51 | /** 52 | The font of the top month label. 53 | Defaults to system font size 16. 54 | */ 55 | @property (nonatomic, strong, nonnull, readwrite) UIFont *monthLabelFont; 56 | 57 | /** 58 | The text color of a cell in a non-selected state. 59 | Defaults to black color. 60 | */ 61 | @property (nonatomic, strong, nonnull, readwrite) UIColor *textColor; 62 | 63 | /** 64 | The text color of a cell when the cell is selected. 65 | Defaults to white color. 66 | */ 67 | @property (nonatomic, strong, nonnull, readwrite) UIColor *selectedTextColor; 68 | 69 | /** 70 | The text color for a weekend day. 71 | Defaults to black. 72 | */ 73 | @property (nonatomic, strong, nonnull, readwrite) UIColor *weekendTextColor; 74 | 75 | /** 76 | Optionally specified to provide a background view for the date picker collection view. 77 | */ 78 | @property (nonatomic, copy, nullable, readwrite) UIView *_Nonnull(^datePickerBackgroundProvider)(void); 79 | 80 | /** 81 | Apply any custom styling to the date picker view here. 82 | 83 | This block runs when the config object is read during the @c TCNDatePicker initialization process. 84 | */ 85 | @property (nonatomic, copy, nullable, readwrite) void(^customDatePickerViewConfig)(UIView *_Nonnull); 86 | 87 | /** 88 | @return An instance of @c TCNDatePickerConfig with the default configuration. 89 | */ 90 | - (nonnull instancetype)init NS_DESIGNATED_INITIALIZER; 91 | 92 | @end 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tachyon 2 | --- 3 | Tachyon provides configurable UI components commonly used in calendar features and applications. 4 | 5 | [Click here for the Android version.](https://github.com/linkedin/Tachyon) 6 | 7 | # Why use Tachyon 8 | --- 9 | Tachyon is a library written in Objective-C but fully compatible with Swift 4. While there are a few libraries out there that have similar features, what sets Tachyon apart are the following: 10 | - Granular customization of color and fonts of all main components 11 | - Handling different time zones with different weekends 12 | - Full support of internationalization (including RTL languages) 13 | 14 | We are also actively working on this library, which means more features and customization (including support for VoiceOver accessibility) will be coming in the near future! 15 | 16 | # Overview 17 | --- 18 | Tachyon consists of two primary components: 19 | 20 | ### TCNDatePicker 21 | This is a pannable view that allows users to select dates of the year. It supports all locale formats, including right-to-left layout. 22 | 23 | ![Date Picker](Resources/date_picker.png) 24 | 25 | ### TCNDayView 26 | This is the calendar view of a single day, which can display events or time slots. The event data is held in `TCNEvent`, which closely mirrors Apple's `EKEvent` in EventKit. Users can view events, scroll, and tap to create or dismiss events. 27 | 28 | ![Day View](Resources/day_view.png) 29 | 30 | # Getting Started 31 | --- 32 | ### Installation 33 | Add the following line to your Podfile: 34 | ```Ruby 35 | pod 'Tachyon' 36 | ``` 37 | 38 | Then, run `pod install`. 39 | 40 | You may also clone or download this repository and drag the Tachyon folder into your iOS project. 41 | 42 | ### Integrating with your project 43 | Import the library: 44 | ```swift 45 | import Tachyon 46 | ``` 47 | 48 | ### Importing UI components 49 | 50 | ```swift 51 | override func viewDidLoad() { 52 | super.viewDidLoad() 53 | 54 | let datePickerConfig = TCNDatePickerConfig() 55 | let datePicker = TCNDatePickerView(frame: CGRect.zero, config: datePickerConfig) 56 | datePicker.datePickerDelegate = self 57 | 58 | view.addSubview(datePicker) 59 | 60 | let dayViewConfig = TCNDayViewConfig() 61 | let dayView = TCNDayView(frame: CGRect.zero, config: dayViewConfig) 62 | dayView.dataSource = self 63 | dayView.delegate = self 64 | 65 | view.addSubview(dayView) 66 | } 67 | ``` 68 | 69 | # Sample application 70 | Build and run the `TachyonSampleApp` target. This is a simple implementation of the library that allows users to select different dates and create half-hour long events on them. 71 | 72 | # Testing 73 | The project includes a unit test and UI test target, providing coverage of the basic layout and functionality of the product. When adding a new feature, be sure to add unit tests and a basic layout test if applicable. 74 | 75 | Test targets are configured to run in English, with a United States locale. This is to enforce consistency in application behavior during testing. If you wish to test for a specific locale, please inject that locale *specifically* for your test, and do not change any schemes to dynamic locale. This will break tests in certain regions. 76 | -------------------------------------------------------------------------------- /Tachyon/Public API/Models/TCNEvent.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | @interface TCNEvent : NSObject 4 | 5 | /** 6 | The name of the event. 7 | */ 8 | @property (nonatomic, copy, nonnull, readonly) NSString *name; 9 | 10 | /** 11 | A string describing the location of the event. 12 | */ 13 | @property (nonatomic, copy, nullable, readonly) NSString *location; 14 | 15 | /** 16 | Timezone info related to the event. 17 | */ 18 | @property (nonatomic, strong, nullable, readonly) NSTimeZone *timezone; 19 | 20 | /** 21 | Start time and date of the event. 22 | */ 23 | @property (nonatomic, strong, nonnull, readonly) NSDate *startDateTime; 24 | 25 | /** 26 | End time and date of the event. 27 | */ 28 | @property (nonatomic, strong, nonnull, readonly) NSDate *endDateTime; 29 | 30 | /** 31 | Whether the event is a full day event. 32 | */ 33 | @property (nonatomic, assign, readonly) BOOL isAllDay; 34 | 35 | /** 36 | Whether the event is created due to a user selection action. 37 | */ 38 | @property (nonatomic, assign, readwrite) BOOL isSelected; 39 | 40 | /** 41 | A human-readable string for this event's time and duration. 42 | */ 43 | @property (nonatomic, copy, nonnull, readonly) NSString *displayTimeString; 44 | 45 | /** 46 | A new event with the specified data. 47 | 48 | @return An instance of @c TCNEvent, assuming the provided input is valid. 49 | A valid input has an endDateTime equal to or later than the startDateTime. 50 | */ 51 | - (nullable instancetype)initWithName:(nonnull NSString *)name 52 | startDateTime:(nonnull NSDate *)startDateTime 53 | endDateTime:(nonnull NSDate *)endDateTime 54 | location:(nullable NSString *)location 55 | timezone:(nullable NSTimeZone *)timezone 56 | isAllDay:(BOOL)isAllDay; 57 | 58 | /** 59 | A new @c TCNEvent with @c startDateTime and a length of one hour. 60 | 61 | @param name The event name or title. 62 | @param startDateTime The starting time of the event. 63 | @return A @c TCNEvent instance with a duration of one hour. 64 | */ 65 | - (nonnull instancetype)initWithName:(nonnull NSString *)name 66 | startDateTime:(nonnull NSDate *)startDateTime; 67 | 68 | /** 69 | Asks if this event occurs on the given date. 70 | 71 | @param date The reference date. 72 | @return YES if the event occurs on the same day as the given date, NO otherwise. If this is a multi-day event, 73 | @c YES if any part of this event occurs on the same day as the given date. 74 | */ 75 | - (BOOL)occursOnDay:(nonnull NSDate *)date; 76 | 77 | /** 78 | Merges overlapping and adjacent events in an array of @c TCNEvent objects. 79 | Merged events will adopt all of the properties of the earliest composing event besides the end time. 80 | 81 | @param events The array of events to merge. 82 | @return The sequence of events provided, with all adjacent or overlapping events merged into one. 83 | All day events are not merged and are also returned in the resulting sequence. 84 | */ 85 | + (nonnull NSArray *)mergedEventsForEvents:(nonnull NSArray *)events; 86 | 87 | @end 88 | -------------------------------------------------------------------------------- /Tachyon/Helpers/TCNDateFormatter.m: -------------------------------------------------------------------------------- 1 | #import "TCNDateFormatter.h" 2 | 3 | @implementation TCNDateFormatter 4 | 5 | + (nonnull NSDateFormatter *)dayOfWeekFormatter { 6 | static NSDateFormatter *dayOfWeekFormatter; 7 | static dispatch_once_t onceToken; 8 | dispatch_once(&onceToken, ^{ 9 | dayOfWeekFormatter = [[NSDateFormatter alloc] init]; 10 | [dayOfWeekFormatter setLocale:[NSLocale currentLocale]]; 11 | dayOfWeekFormatter.dateFormat = [NSDateFormatter dateFormatFromTemplate:@"ccccc" options:0 locale:dayOfWeekFormatter.locale]; 12 | }); 13 | return dayOfWeekFormatter; 14 | } 15 | 16 | + (nonnull NSDateFormatter *)dayOfMonthFormatter { 17 | static NSDateFormatter *dayOfMonthFormatter; 18 | static dispatch_once_t onceToken; 19 | dispatch_once(&onceToken, ^{ 20 | dayOfMonthFormatter = [[NSDateFormatter alloc] init]; 21 | [dayOfMonthFormatter setLocale:[NSLocale currentLocale]]; 22 | dayOfMonthFormatter.dateFormat = [NSDateFormatter dateFormatFromTemplate:@"d" options:0 locale:dayOfMonthFormatter.locale]; 23 | }); 24 | return dayOfMonthFormatter; 25 | } 26 | 27 | + (nonnull NSDateFormatter *)monthAndYearFormatter { 28 | static NSDateFormatter *monthAndYearFormatter; 29 | static dispatch_once_t onceToken; 30 | dispatch_once(&onceToken, ^{ 31 | monthAndYearFormatter = [[NSDateFormatter alloc] init]; 32 | [monthAndYearFormatter setLocale:[NSLocale currentLocale]]; 33 | monthAndYearFormatter.dateFormat = [NSDateFormatter dateFormatFromTemplate:@"MMMM, yyyy" options:0 locale:monthAndYearFormatter.locale]; 34 | }); 35 | return monthAndYearFormatter; 36 | } 37 | 38 | + (nonnull NSDateFormatter *)sidebarTimeFormatter { 39 | static NSDateFormatter *sidebarTimeFormatter; 40 | static dispatch_once_t onceToken; 41 | dispatch_once(&onceToken, ^{ 42 | sidebarTimeFormatter = [[NSDateFormatter alloc] init]; 43 | [sidebarTimeFormatter setLocale:[NSLocale currentLocale]]; 44 | sidebarTimeFormatter.dateFormat = [NSDateFormatter dateFormatFromTemplate:@"jj" options:0 locale:sidebarTimeFormatter.locale]; 45 | }); 46 | return sidebarTimeFormatter; 47 | } 48 | 49 | + (nonnull NSDateFormatter *)timeFormatter { 50 | static NSDateFormatter *timeFormatter; 51 | static dispatch_once_t onceToken; 52 | dispatch_once(&onceToken, ^{ 53 | timeFormatter = [[NSDateFormatter alloc] init]; 54 | [timeFormatter setLocale:[NSLocale currentLocale]]; 55 | timeFormatter.timeStyle = NSDateFormatterShortStyle; 56 | }); 57 | return timeFormatter; 58 | } 59 | 60 | #pragma mark - TESTING 61 | 62 | + (nonnull NSDateFormatter *)testing_monthDayYearFormatter { 63 | static NSDateFormatter *testing_monthDayYearFormatter; 64 | static dispatch_once_t onceToken; 65 | dispatch_once(&onceToken, ^{ 66 | testing_monthDayYearFormatter = [[NSDateFormatter alloc] init]; 67 | testing_monthDayYearFormatter.dateFormat = [NSDateFormatter dateFormatFromTemplate:@"ccc MMM dd, yyyy" 68 | options:0 69 | locale:[NSLocale localeWithLocaleIdentifier:@"en_us"]]; 70 | }); 71 | return testing_monthDayYearFormatter; 72 | } 73 | 74 | @end 75 | -------------------------------------------------------------------------------- /TachyonUITests/TachyonUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | /** 4 | To optimize test speed, this test does not terminate and restart the application between tests. 5 | Instead, it backgrounds and foregrounds the application, which should reset any state via 6 | logic living in `TachyonSampleApp.ViewController`. 7 | */ 8 | class TachyonUITests: XCTestCase { 9 | 10 | private static var application: XCUIApplication = XCUIApplication() 11 | 12 | override static func setUp() { 13 | super.setUp() 14 | application = XCUIApplication() 15 | 16 | // Here, we speed up animations instead of disabling them entirely. This helps us avoid missing any 17 | // animation-dependent issues. 18 | // See https://stackoverflow.com/questions/37282350/how-to-speed-up-ui-test-cases-in-xcode 19 | UIApplication.shared.keyWindow?.layer.speed = 100 20 | 21 | XCUIDevice.shared.orientation = .portrait 22 | } 23 | 24 | override func setUp() { 25 | super.setUp() 26 | 27 | continueAfterFailure = false 28 | TachyonUITests.application.activate() 29 | } 30 | 31 | override func tearDown() { 32 | super.setUp() 33 | 34 | XCUIDevice().press(.home) 35 | } 36 | 37 | func testBasicLayout() { 38 | let app = TachyonUITests.application 39 | 40 | // We default the collection view offset to 8 AM, so 9 AM should exist somewhere. 41 | XCTAssertTrue(app.staticTexts["9 AM"].exists) 42 | } 43 | 44 | func testCreateEvent() { 45 | let app = TachyonUITests.application 46 | 47 | // Tap in the middle of the screen to create a cell 48 | app.children(matching: .window) 49 | .element(boundBy: 0) 50 | .children(matching: .other) 51 | .element 52 | .children(matching: .other) 53 | .element(boundBy: 1) 54 | .children(matching: .collectionView) 55 | .element 56 | .tap() 57 | 58 | XCTAssertTrue(app.staticTexts["Available"].exists) 59 | XCTAssertTrue(app.collectionViews.cells.buttons["ic cancel 16dp"].exists) 60 | 61 | // Tap the close button 62 | app.collectionViews.cells.buttons["ic cancel 16dp"].tap() 63 | XCTAssertFalse(app.staticTexts["Available"].exists) 64 | } 65 | 66 | func testSelectDay() { 67 | let app = TachyonUITests.application 68 | let collectionViewsQuery = XCUIApplication().collectionViews 69 | 70 | // Tap on Wednesday 71 | collectionViewsQuery.cells.otherElements.containing(.staticText, identifier: "W").element.tap() 72 | XCTAssertTrue(app.staticTexts["Eat breakfast cookies"].waitForExistence(timeout: 1)) 73 | 74 | // Tap on Monday 75 | collectionViewsQuery.cells.otherElements.containing(.staticText, identifier: "M").element.tap() 76 | XCTAssertTrue(app.staticTexts["Meeting"].waitForExistence(timeout: 1)) 77 | 78 | // Create an event cell 79 | app.children(matching: .window) 80 | .element(boundBy: 0) 81 | .children(matching: .other) 82 | .element 83 | .children(matching: .other) 84 | .element(boundBy: 1) 85 | .children(matching: .collectionView) 86 | .element 87 | .tap() 88 | XCTAssertTrue(app.staticTexts["Available"].waitForExistence(timeout: 1)) 89 | 90 | // Switch back to Wednesday 91 | collectionViewsQuery.cells.otherElements.containing(.staticText, identifier: "W").element.tap() 92 | XCTAssertTrue(app.staticTexts["Eat breakfast cookies"].waitForExistence(timeout: 1)) 93 | XCTAssertFalse(app.staticTexts["Available"].waitForExistence(timeout: 1)) 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /TachyonTests/Date Picker/TCNDatePickerTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import 4 | 5 | #import "TCNDatePickerView.h" 6 | #import "TCNDatePickerDayView.h" 7 | #import "TCNDateUtil.h" 8 | #import "TCNDatePickerSelectionIndicatorView.h" 9 | 10 | @interface TCNDatePickerView (Testing) 11 | 12 | @property (nonatomic, strong, nonnull, readonly) UICollectionView *collectionView; 13 | 14 | @end 15 | 16 | @interface TCNDatePickerTests : LYTLayoutTestCase 17 | 18 | @end 19 | 20 | @implementation TCNDatePickerTests 21 | 22 | - (void)testLayout { 23 | [self runLayoutTestsWithViewProvider:[self class] 24 | validation:^(TCNDatePickerView *_Nonnull view, NSDictionary * _Nonnull data, id _Nullable context) { 25 | [self ignoreSelectionIndicatorViewOnCollectionView:view.collectionView]; 26 | [self ignoreNonActiveCellsOnDatePicker:view]; 27 | }]; 28 | } 29 | 30 | - (void)testDateSelection { 31 | UIViewController *const viewController = [[UIViewController alloc] init]; 32 | CGRect datePickerFrame = CGRectMake(0, 0, viewController.view.frame.size.width, 50); 33 | TCNDatePickerView *const datePicker = [[TCNDatePickerView alloc] initWithFrame:datePickerFrame]; 34 | [viewController.view addSubview:datePicker]; 35 | 36 | NSDateFormatter *const formatter = [[NSDateFormatter alloc] init]; 37 | formatter.dateFormat = @"yyyy-MM-dd HH:mm:ss"; 38 | NSDate *const date = [formatter dateFromString:@"2019-01-25 13:30:23"]; 39 | [datePicker selectDate:date animated:NO]; 40 | 41 | XCTAssertTrue([TCNDateUtil isDate:date inSameDayAsDate:datePicker.selectedDate]); 42 | } 43 | 44 | #pragma mark - LYTViewProvider 45 | 46 | + (NSDictionary *)dataSpecForTest { 47 | return @{}; 48 | } 49 | 50 | + (UIView *)viewForData:(NSDictionary *)data reuseView:(UIView *)reuseView size:(LYTViewSize *)size context:(id _Nullable __autoreleasing *)context { 51 | CGRect datePickerFrame = CGRectMake( 52 | 0, 53 | 0, 54 | UIScreen.mainScreen.bounds.size.width, 55 | [TCNDatePickerView heightRequiredForConfig:[[TCNDatePickerConfig alloc] init]]); 56 | TCNDatePickerView *const datePicker = [[TCNDatePickerView alloc] initWithFrame:datePickerFrame]; 57 | 58 | // We need to call willMoveToSuperview to set the delegate and dataSource of TCNDatePickerView. 59 | [datePicker willMoveToSuperview:[[UIView alloc] init]]; 60 | [datePicker selectDate:[NSDate date] animated:NO]; 61 | 62 | return datePicker; 63 | } 64 | 65 | #pragma mark - Helpers 66 | 67 | - (void)ignoreSelectionIndicatorViewOnCollectionView:(nonnull UICollectionView *)collectionView { 68 | for (UIView *view in collectionView.subviews) { 69 | if ([view isKindOfClass:[TCNDatePickerSelectionIndicatorView class]]) { 70 | [[self viewsAllowingOverlap] addObject:view]; 71 | } 72 | } 73 | } 74 | 75 | /** 76 | Ignores subviews for all cells not in the active week for the given date picker, 77 | and ignores the cells themselves since layout calculation does not work with UICollectionViews. 78 | */ 79 | - (void)ignoreNonActiveCellsOnDatePicker:(nonnull TCNDatePickerView *)datePicker { 80 | NSArray *const activeWeekDates = [TCNDateUtil daysOfWeekFromDate:datePicker.selectedDate]; 81 | for (UIView *view in datePicker.collectionView.subviews) { 82 | if ([view isKindOfClass:[TCNDatePickerDayView class]]) { 83 | TCNDatePickerDayView *const cell = ((TCNDatePickerDayView *)view); 84 | if (![activeWeekDates containsObject:cell.date]) { 85 | [self recursivelyIgnoreOverlappingSubviewsOnView:cell]; 86 | } 87 | [[self viewsAllowingOverlap] addObject:cell]; 88 | } 89 | } 90 | } 91 | 92 | @end 93 | -------------------------------------------------------------------------------- /Tachyon.xcodeproj/xcshareddata/xcschemes/TachyonSampleApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 68 | 69 | 70 | 71 | 72 | 73 | 79 | 81 | 87 | 88 | 89 | 90 | 92 | 93 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDayView.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNEvent.h" 3 | #import "TCNDayViewConfig.h" 4 | 5 | @class TCNDayView; 6 | 7 | #pragma mark - TCNDayViewDelegate 8 | 9 | /** 10 | Classes implementing this protocol may receive updates for UI events from the @c TCNDayView. 11 | */ 12 | @protocol TCNDayViewDelegate 13 | 14 | /** 15 | Called when a timeslot is tapped on the calendar view and a selected event is created for that timeslot. 16 | 17 | The event created will have a start time equal to the nearest interval to the tap location, 18 | of interval granularity equal to @c TCNDayViewConfig.defaultEventLength. 19 | The event created will have a length equal to @c TCNDayViewConfig.defaultEventLength. 20 | 21 | @param dayView The @c TCNDayView responding to this UI event. 22 | @param event The new @c TCNEvent for the timeslot selected. 23 | */ 24 | - (void)dayView:(nonnull TCNDayView *)dayView didSelectAvailabilityWithEvent:(nonnull TCNEvent *)event; 25 | 26 | /** 27 | Called when the cancel button on an event is tapped, or when a created event is tapped. 28 | 29 | @param dayView The @c TCNDayView responding to this UI event. 30 | @param event The cancelled @c TCNEvent. 31 | */ 32 | @optional 33 | - (void)dayView:(nonnull TCNDayView *)dayView didCancelEvent:(nonnull TCNEvent *)event; 34 | 35 | @end 36 | 37 | #pragma mark - TCNDayViewDataSource 38 | 39 | /** 40 | Classes implementing this protocol are responsible for providing the underlying data necessary to populate the 41 | @c TCNDayView. 42 | 43 | The day view contains two separate sections for all day events and non-all day events. This is implemented as 44 | two separate @c UICollectionView instances. 45 | */ 46 | @protocol TCNDayViewDataSource 47 | 48 | /** 49 | The date for which the day view is currently displaying events. 50 | */ 51 | @property (nonatomic, strong, nonnull, readonly) NSDate *currentDate; 52 | 53 | /** 54 | The events for this day that have specific time slots, i.e. not all-day events. 55 | 56 | e.g. In Apple EventKit, this would map to @c EKEvent.allDay == false 57 | */ 58 | @property (nonatomic, strong, nonnull, readonly) NSArray *dayEvents; 59 | 60 | /** 61 | The events for this day that span the entire day. 62 | 63 | e.g. In Apple EventKit, this would map to @c EKEvent.allDay == true. 64 | */ 65 | @property (nonatomic, strong, nonnull, readonly) NSArray *allDayEvents; 66 | 67 | @end 68 | 69 | #pragma mark - TCNDayView 70 | 71 | /** 72 | Displays a day's calendar event view. Users may interact with the day view to create new events. 73 | The day view also supports loading existing events from a data source, e.g. Apple EventKit. 74 | */ 75 | @interface TCNDayView : UIView 76 | 77 | /** 78 | The day view's delegate, which may respond to events from the day view. 79 | */ 80 | @property (nonatomic, weak, nullable, readwrite) id delegate; 81 | 82 | /** 83 | The day view's data source, which is responsible for providing events to populate the view. 84 | */ 85 | @property (nonatomic, weak, nullable, readwrite) id dataSource; 86 | 87 | /** 88 | The default hour of the day view. Defaults to 8AM local time. 89 | */ 90 | @property (nonatomic, assign, readwrite) NSInteger defaultHour; 91 | 92 | /** 93 | Initialize the day view with a frame and config. The config will be read during the initialization process. 94 | 95 | @param frame The starting frame. 96 | @param config The configuration object for UI styling. 97 | @return An @c TCNDayView instance. 98 | */ 99 | - (nonnull instancetype)initWithFrame:(CGRect)frame config:(nonnull TCNDayViewConfig *)config NS_DESIGNATED_INITIALIZER; 100 | 101 | - (nonnull instancetype)initWithCoder:(nonnull NSCoder *)aDecoder NS_UNAVAILABLE; 102 | 103 | /** 104 | Reloads the day view. 105 | 106 | This method should be called when a new date is selected for this day view. 107 | 108 | @param resetScrolling If true, the offset of the day will reset such that the @c defaultHour is at the top. 109 | */ 110 | - (void)reloadAndResetScrolling:(BOOL)resetScrolling 111 | NS_SWIFT_NAME(reload(resetScrolling:)); 112 | 113 | @end 114 | -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNDayViewLayout.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import "TCNDayViewConfig.h" 3 | 4 | @class TCNDayViewLayout; 5 | 6 | #pragma mark - TCNDayViewLayoutDelegate 7 | 8 | /** 9 | Classes implementing this protocol are responsible for providing information necessary to layout a @c TCNDayView correctly. 10 | */ 11 | @protocol TCNDayViewLayoutDelegate 12 | 13 | /** 14 | Asks for an event start time at the given index path. The layout will then use this start time 15 | to render any events at this index path. 16 | 17 | @param collectionView The calling collection view. 18 | @param collectionViewLayout The calling layout. 19 | @param indexPath The index path for which to return information. 20 | @return An @c NSDate start time. 21 | */ 22 | - (nonnull NSDate *)collectionView:(nullable UICollectionView *)collectionView 23 | layout:(nonnull TCNDayViewLayout *)collectionViewLayout 24 | startTimeForItemAtIndexPath:(nonnull NSIndexPath *)indexPath; 25 | 26 | /** 27 | Asks for an event end time at the given index path. The layout will then use this end time 28 | to render any events at this index path. 29 | 30 | @param collectionView The calling collection view. 31 | @param collectionViewLayout The calling layout. 32 | @param indexPath The index path for which to return information. 33 | @return An @c NSDate end time. 34 | */ 35 | - (nonnull NSDate *)collectionView:(nullable UICollectionView *)collectionView 36 | layout:(nonnull TCNDayViewLayout *)collectionViewLayout 37 | endTimeForItemAtIndexPath:(nonnull NSIndexPath *)indexPath; 38 | 39 | /** 40 | Asks if the event at the given index path should have its layout adjusted to accomodate other events 41 | at that index path. If false, this event will lay on top of other events in its time slot. 42 | 43 | @param collectionView The calling collection view. 44 | @param collectionViewLayout The calling layout. 45 | @param indexPath The index path for which to return information. 46 | @return YES if the event should have layout adjusted, NO otherwise. 47 | */ 48 | - (BOOL)collectionView:(nullable UICollectionView *)collectionView 49 | layout:(nonnull TCNDayViewLayout *)collectionViewLayout 50 | shouldAdjustLayoutForItemAtIndexPath:(nonnull NSIndexPath *)indexPath; 51 | 52 | @end 53 | 54 | #pragma mark - TCNDayViewLayout 55 | 56 | /** 57 | The default layout for the @c TCNDayView non-all day collection view. 58 | 59 | For the layout used for the all day event collection view, refer to @c TCNAllDayViewLayout. 60 | */ 61 | @interface TCNDayViewLayout : UICollectionViewLayout 62 | 63 | /** 64 | The collection view's default top content inset. 65 | */ 66 | @property (nonatomic, assign, class, readonly) CGFloat topInsetMargin; 67 | 68 | /** 69 | The delegate of this @c TCNDayViewLayout. 70 | */ 71 | @property (nonatomic, weak, nullable, readwrite) id delegate; 72 | 73 | /** 74 | A new day view layout with the specified @c config. 75 | 76 | @param config The configuration object for UI styling. 77 | @return A @c TCNDayViewLayout instance. 78 | */ 79 | - (nonnull instancetype)initWithConfig:(nonnull TCNDayViewConfig *)config NS_DESIGNATED_INITIALIZER; 80 | 81 | - (nonnull instancetype)initWithCoder:(nonnull NSCoder *)aDecoder NS_UNAVAILABLE; 82 | 83 | - (nonnull instancetype)init NS_UNAVAILABLE; 84 | 85 | /** 86 | The offset for the top of the time slot specified by @c indexPath. 87 | 88 | @param indexPath An index path corresponding to a time slot. 89 | @param minY The reference Y value corresponding to the top of the day view time slots. 90 | @return A floating point offset for the given indexPath. 91 | */ 92 | + (CGFloat)offsetForIndexPath:(nonnull NSIndexPath *)indexPath minY:(CGFloat)minY; 93 | 94 | /** 95 | An NSDate with nil date components, with the hour and minute set according to the provided collection view offset. 96 | 97 | @param offset A Y value corresponding to a time of day. 98 | @return A date corresponding to the provided y value. 99 | */ 100 | + (nonnull NSDate *)timeForYOffset:(CGFloat)offset; 101 | 102 | @end 103 | -------------------------------------------------------------------------------- /Tachyon.xcodeproj/xcshareddata/xcschemes/Tachyon.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 53 | 54 | 60 | 61 | 62 | 63 | 64 | 65 | 75 | 76 | 82 | 83 | 84 | 85 | 86 | 87 | 93 | 94 | 100 | 101 | 102 | 103 | 105 | 106 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /Tachyon/Public API/Models/TCNEvent.m: -------------------------------------------------------------------------------- 1 | #import "TCNEvent.h" 2 | #import "TCNDateFormatter.h" 3 | #import "TCNDateUtil.h" 4 | #import "TCNMacros.h" 5 | 6 | @implementation TCNEvent 7 | 8 | #pragma mark - Initialization 9 | 10 | - (nullable instancetype)initWithName:(nonnull NSString *)name 11 | startDateTime:(nonnull NSDate *)startDateTime 12 | endDateTime:(nonnull NSDate *)endDateTime 13 | location:(nullable NSString *)location 14 | timezone:(nullable NSTimeZone *)timezone 15 | isAllDay:(BOOL)isAllDay { 16 | self = [super init]; 17 | if (!self) { 18 | return self; 19 | } 20 | if ([startDateTime compare:endDateTime] == NSOrderedDescending) { 21 | return nil; 22 | } 23 | _name = name; 24 | _startDateTime = startDateTime; 25 | _endDateTime = endDateTime; 26 | _location = location; 27 | _timezone = timezone ?: [NSTimeZone localTimeZone]; 28 | _isAllDay = isAllDay; 29 | return self; 30 | } 31 | 32 | - (nonnull instancetype)initWithName:(nonnull NSString *)name 33 | startDateTime:(nonnull NSDate *)startDateTime { 34 | self = [super init]; 35 | if (!self) { 36 | return self; 37 | } 38 | _name = name; 39 | _startDateTime = startDateTime; 40 | _endDateTime = [startDateTime dateByAddingTimeInterval:3600]; 41 | _timezone = [NSTimeZone localTimeZone]; 42 | _isAllDay = false; 43 | return self; 44 | } 45 | 46 | #pragma mark - Methods 47 | 48 | - (nonnull NSString *)displayTimeString { 49 | NSDateFormatter *const formatter = TCNDateFormatter.timeFormatter; 50 | return [NSString stringWithFormat:@"%@ - %@", 51 | [formatter stringFromDate:self.startDateTime], 52 | [formatter stringFromDate:self.endDateTime]]; 53 | } 54 | 55 | - (BOOL)occursOnDay:(nonnull NSDate *)date { 56 | return [TCNDateUtil isDate:date inSameDayAsDate:self.startDateTime] 57 | || [TCNDateUtil isDate:date inSameDayAsDate:self.endDateTime] 58 | || ([TCNDateUtil date:date isAfterDate:self.startDateTime] && [TCNDateUtil date:date isBeforeDate:self.endDateTime]); 59 | } 60 | 61 | #pragma mark - NSObject 62 | 63 | - (NSString *)description { 64 | return [NSString stringWithFormat:@"TCNEvent {\nname: %@\nstartTime: %@\nendTime: %@\nisAllDay: %d\nisSelected: %d\n}", 65 | self.name, 66 | self.startDateTime, 67 | self.endDateTime, 68 | self.isAllDay, 69 | self.isSelected]; 70 | } 71 | 72 | #pragma mark - Static Methods 73 | 74 | + (nonnull NSArray *)mergedEventsForEvents:(nonnull NSArray *)events { 75 | NSMutableArray *const mergedEvents = [[NSMutableArray alloc] init]; 76 | NSArray *const sortedEvents = [events sortedArrayUsingComparator:^NSComparisonResult(TCNEvent *_Nonnull obj1, TCNEvent *_Nonnull obj2) { 77 | return [obj1.startDateTime compare:obj2.startDateTime]; 78 | }]; 79 | 80 | NSUInteger eventIndex = 0; 81 | while (eventIndex < sortedEvents.count) { 82 | TCNEvent *const event = sortedEvents[eventIndex]; 83 | if (event.isAllDay) { 84 | [mergedEvents addObject:event]; 85 | eventIndex += 1; 86 | continue; 87 | } 88 | NSDate *newEndDateTime = event.endDateTime; 89 | 90 | while (eventIndex < sortedEvents.count - 1) { 91 | NSDate *const nextStartTime = sortedEvents[eventIndex + 1].startDateTime; 92 | NSDate *const nextEndTime = sortedEvents[eventIndex + 1].endDateTime; 93 | if (![TCNDateUtil date:nextStartTime isAfterDate:newEndDateTime]) { 94 | newEndDateTime = [TCNDateUtil latestDate:newEndDateTime otherDate:nextEndTime]; 95 | eventIndex += 1; 96 | } else { 97 | break; 98 | } 99 | } 100 | 101 | TCNEvent *const newEvent = [[TCNEvent alloc] initWithName:event.name 102 | startDateTime:event.startDateTime 103 | endDateTime:newEndDateTime 104 | location:event.location 105 | timezone:event.timezone 106 | isAllDay:event.isAllDay]; 107 | if (!newEvent) { 108 | TCN_ASSERT_FAILURE(@"Failed to construct merged calendar event."); 109 | eventIndex += 1; 110 | continue; 111 | } 112 | 113 | [mergedEvents addObject:newEvent]; 114 | eventIndex += 1; 115 | } 116 | 117 | return mergedEvents; 118 | } 119 | 120 | @end 121 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDateUtil.h: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | /** 4 | Contains helper functions for date manipulation. 5 | 6 | Many NSCalendar operations return optional values. In practice, these methods almost never return nil even when passed 7 | a seemingly invalid argument, e.g.: 8 | 9 | @code 10 | var components = DateComponents() 11 | components.year = -1 12 | components.month = 13 13 | Calendar.date(from: components) 14 | 15 | -> Date? = -001-12-30 07:52:58 UTC 16 | @endcode 17 | 18 | Therefore, some of the helper methods here that wrap these NSCalendar methods return 19 | non-optional values by simply coalescing to the given date. 20 | */ 21 | @interface TCNDateUtil : NSObject 22 | 23 | #pragma mark - Date manipulation 24 | 25 | /** 26 | Requests the provided hour, local time, for the provided date. 27 | 28 | @param date The reference date. 29 | @param hour The hour to set on the reference date. 30 | @return An @c NSDate set to the given hour on the reference date. 31 | */ 32 | + (nonnull NSDate *)dateWithDate:(nonnull NSDate *)date atHour:(NSInteger)hour; 33 | 34 | /** 35 | Requests the provided hour & minute, local time, for the provided date. 36 | 37 | @param date The reference date. 38 | @param hour The hour to set on the reference date. 39 | @param minute The minute to set on the reference date. 40 | @return An @c NSDate set to the given hour and minute on the reference date. 41 | */ 42 | + (nonnull NSDate *)dateWithDate:(nonnull NSDate *)date atHour:(NSInteger)hour andMinute:(NSInteger)minute; 43 | 44 | /** 45 | Requests the reference date, adding the number of days provided. 46 | 47 | @param days The number of days to add to the reference date. 48 | @param date The reference date. 49 | @return An @c NSDate at the time of the reference date, plus the given number of days. 50 | */ 51 | + (nonnull NSDate *)dateByAddingDays:(NSInteger)days toDate:(nonnull NSDate *)date; 52 | 53 | /** 54 | Requests the reference date, adding the number of weeks provided. 55 | 56 | @param weeks The number of weeks to add to the reference date. 57 | @param date The reference date. 58 | @return An @c NSDate at the time of the reference date, plus the given number of weeks. 59 | */ 60 | + (nonnull NSDate *)dateByAddingWeeks:(NSInteger)weeks toDate:(nonnull NSDate *)date; 61 | 62 | /** 63 | Requests the middle day of the week for the date provided. The middle day is the 4th day of that week. 64 | In most locales, this translates to Wednesday since the week starts on Sunday. 65 | 66 | @param date The reference date. 67 | @return An @c NSDate set to the middle day of the week of the reference date. 68 | */ 69 | + (nonnull NSDate *)middleOfWeekForDate:(nonnull NSDate *)date; 70 | 71 | /** 72 | Requests the end of the day for the date provided. The end of the day is defined as the last second of that day, 73 | or the second before midnight. 74 | 75 | @param date The reference date. 76 | @return The end of the day (e.g. 11:59 p.m.) on the reference date. 77 | */ 78 | + (nonnull NSDate *)endOfDayForDate:(nonnull NSDate *)date; 79 | 80 | #pragma mark - Date comparison 81 | 82 | /** 83 | @param date The first date to compare. 84 | @param otherDate The second date to compare. 85 | @return YES if the two dates are in the same day in the local timezone. 86 | */ 87 | + (BOOL)isDate:(nonnull NSDate *)date inSameDayAsDate:(nonnull NSDate *)otherDate; 88 | 89 | /** 90 | @param date The first date to compare. 91 | @param otherDate The second date to compare. 92 | @return YES if the two dates fall in to the same week in the local timezone. 93 | */ 94 | + (BOOL)isDate:(nonnull NSDate *)date inSameWeekAsDate:(nonnull NSDate *)otherDate; 95 | 96 | /** 97 | @param date The first date to compare. 98 | @param otherDate The second date to compare. 99 | @return YES if the first date is after the second. 100 | */ 101 | + (BOOL)date:(nonnull NSDate *)date isAfterDate:(nonnull NSDate *)otherDate; 102 | 103 | /** 104 | @param date The first date to compare. 105 | @param otherDate The second date to compare. 106 | @return YES if the first date is before the second. 107 | */ 108 | + (BOOL)date:(nonnull NSDate *)date isBeforeDate:(nonnull NSDate *)otherDate; 109 | 110 | /** 111 | @param date The first date to compare. 112 | @param otherDate The second date to compare. 113 | @return The later of the two dates. 114 | */ 115 | + (nonnull NSDate *)latestDate:(nonnull NSDate *)date otherDate:(nonnull NSDate *)otherDate; 116 | 117 | /** 118 | @param date The first date to compare. 119 | @param otherDate The second date to compare. 120 | @return The earlier of the two dates. 121 | */ 122 | + (nonnull NSDate *)earliestDate:(nonnull NSDate *)date otherDate:(nonnull NSDate *)otherDate; 123 | 124 | #pragma mark - Other 125 | 126 | /** 127 | Requests an integer representing the index of the day for the week from 0 to 6, where the first day of the week 128 | in the current locale corresponds to 0 and the last to 6. 129 | 130 | @param date The reference date. 131 | @return The index of the day in its week. 132 | */ 133 | + (NSInteger)indexOfDateInWeek:(nonnull NSDate *)date; 134 | 135 | /** 136 | @example 137 | For a reference date of Friday Jan 25th, 2019, the days of the week are Sun, Jan 20th 2019 - Sat, Jan 26th 2019 138 | in a locale where Sunday is the first day of the week. 139 | 140 | @param date The reference date. 141 | @return All the days in the week for the reference date. 142 | */ 143 | + (nonnull NSArray *)daysOfWeekFromDate:(nonnull NSDate *)date; 144 | 145 | @end 146 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDayViewConfig.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | #import "TCNReusableView.h" 5 | 6 | @class TCNDayViewConfig; 7 | 8 | /** 9 | Classes implementing this protocol may adopt and use configuration parameters stored in @c TCNDayViewConfig. 10 | */ 11 | @protocol TCNDayViewConfigurable 12 | 13 | /** 14 | Any UI styling of the adopting class using properties stored in @c TCNDayViewConfig should be done here. 15 | 16 | @param config The configuration object 17 | @param selected If true, the object is selected and should reference the corresponding selected values on the config object. 18 | */ 19 | - (void)applyStylingFromConfig:(nonnull TCNDayViewConfig *)config selected:(BOOL)selected; 20 | 21 | @end 22 | 23 | /** 24 | An integer enum representing the supported event lengths. 25 | */ 26 | typedef NS_ENUM(NSInteger, TCNEventLength) { 27 | 28 | TCNEventLengthHalfHour = 30, 29 | TCNEventLengthHour = 60 30 | 31 | }; 32 | 33 | /** 34 | The configuration object for @c TCNDayView. This allows consumers of this view to 35 | modify certain UI properties. 36 | */ 37 | @interface TCNDayViewConfig : NSObject 38 | 39 | /** 40 | The displayed text for any new events created on the day view. 41 | Defaults to "Available". 42 | */ 43 | @property (nonatomic, copy, nonnull, readwrite) NSString *createdEventText; 44 | 45 | /** 46 | The text for the all day event label. 47 | Defaults to "All Day". 48 | */ 49 | @property (nonatomic, copy, nonnull, readwrite) NSString *allDayLabelText; 50 | 51 | /** 52 | The length of a newly created event, in minutes. 53 | Defaults to 30. 54 | */ 55 | @property (nonatomic, assign, readwrite) TCNEventLength defaultEventLength; 56 | 57 | /** 58 | The background color of the collection view. 59 | Defaults to white. 60 | */ 61 | @property (nonatomic, strong, nonnull, readwrite) UIColor *backgroundColor; 62 | 63 | /** 64 | The font of an event cell. 65 | Defaults to system font of size 12. 66 | */ 67 | @property (nonatomic, strong, nonnull, readwrite) UIFont *eventFont; 68 | 69 | /** 70 | The text color of an event cell. 71 | Defaults to black. 72 | */ 73 | @property (nonatomic, strong, nonnull, readwrite) UIColor *eventTextColor; 74 | 75 | /** 76 | The background color of an event cell. 77 | Defaults to light gray. 78 | */ 79 | @property (nonatomic, strong, nonnull, readwrite) UIColor *eventColor; 80 | 81 | /** 82 | The text color of a selected event cell. 83 | Defaults to white. 84 | */ 85 | @property (nonatomic, strong, nonnull, readwrite) UIColor *selectedEventTextColor; 86 | 87 | /** 88 | The background color of a selected event cell. 89 | Defaults to light gray. 90 | */ 91 | @property (nonatomic, strong, nonnull, readwrite) UIColor *selectedEventColor; 92 | 93 | /** 94 | The font of a timeslot cell. 95 | Defaults to system font of size 12. 96 | */ 97 | @property (nonatomic, strong, nonnull, readwrite) UIFont *timeslotFont; 98 | 99 | /** 100 | The text color of an timeslot cell. 101 | Defaults to blue. 102 | */ 103 | @property (nonatomic, strong, nonnull, readwrite) UIColor *timeslotTextColor; 104 | 105 | /** 106 | The background color of a timeslot cell. 107 | Defaults to green. 108 | */ 109 | @property (nonatomic, strong, nonnull, readwrite) UIColor *timeslotColor; 110 | 111 | /** 112 | The font of a sidebar cell. 113 | Defaults to system font of size 12. 114 | */ 115 | @property (nonatomic, strong, nonnull, readwrite) UIFont *sidebarFont; 116 | 117 | /** 118 | The text color of a sidebar. 119 | Defaults to black. 120 | */ 121 | @property (nonatomic, strong, nonnull, readwrite) UIColor *sidebarTextColor; 122 | 123 | /** 124 | The background color of sidebar. 125 | Defaults to white. 126 | */ 127 | @property (nonatomic, strong, nonnull, readwrite) UIColor *sidebarColor; 128 | 129 | /** 130 | The background color of an hour mark gridline view. 131 | Defaults to dark gray. 132 | */ 133 | @property (nonatomic, strong, nonnull, readwrite) UIColor *gridlineDarkColor; 134 | 135 | /** 136 | The background color of a 30 mins mark gridline view. 137 | Defaults to light gray. 138 | */ 139 | @property (nonatomic, strong, nonnull, readwrite) UIColor *gridlineLightColor; 140 | 141 | /** 142 | @c YES if a cancel button should be shown on created event cells. 143 | Default @c YES. 144 | */ 145 | @property (nonatomic, assign, readwrite) BOOL shouldShowCancelButtonOnCreatedEvents; 146 | 147 | /** 148 | The image to be shown for event cells' cancel button. 149 | Optional. 150 | */ 151 | @property (nonatomic, strong, nullable, readwrite) UIImage *cancelButtonImage; 152 | 153 | /** 154 | Provides a background view for the day collection view. 155 | Optional. 156 | */ 157 | @property (nonatomic, copy, nullable, readwrite) UIView *_Nonnull(^dayViewBackgroundProvider)(void); 158 | 159 | /** 160 | Provides a background view for the all day collection view. 161 | Optional. 162 | */ 163 | @property (nonatomic, copy, nullable, readwrite) UIView *_Nonnull(^allDayViewBackgroundProvider)(void); 164 | 165 | /** 166 | Applies custom styling to the day event view. 167 | Optional. 168 | 169 | This block runs when the config object is read during the @c TCNDayView initialization process. 170 | */ 171 | @property (nonatomic, copy, nullable, readwrite) void(^customDayViewConfig)(UIView *_Nonnull); 172 | 173 | /** 174 | Applies custom styling to the all-day event view. 175 | Optional. 176 | 177 | This block runs when the config object is read during the @c TCNDayView initialization process. 178 | */ 179 | @property (nonatomic, copy, nullable, readwrite) void(^customAllDayViewConfig)(UIView *_Nonnull); 180 | 181 | - (nonnull instancetype)init NS_DESIGNATED_INITIALIZER; 182 | 183 | @end 184 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDateUtil.m: -------------------------------------------------------------------------------- 1 | #import "TCNDateUtil.h" 2 | #import "TCNDateFormatter.h" 3 | #import "TCNMacros.h" 4 | 5 | @implementation TCNDateUtil 6 | 7 | #pragma mark - Date manipulation 8 | 9 | + (nonnull NSDate *)dateWithDate:(nonnull NSDate *)date atHour:(NSInteger)hour { 10 | NSDate *const newDate = [[NSCalendar currentCalendar] dateBySettingHour:hour minute:0 second:0 ofDate:date options:0]; 11 | if (!newDate) { 12 | return date; 13 | } 14 | return newDate; 15 | } 16 | 17 | + (nonnull NSDate *)dateWithDate:(nonnull NSDate *)date atHour:(NSInteger)hour andMinute:(NSInteger)minute { 18 | NSDate *const newDate = [[NSCalendar currentCalendar] dateBySettingHour:hour 19 | minute:minute 20 | second:0 21 | ofDate:date 22 | options:0]; 23 | if (!newDate) { 24 | return date; 25 | } 26 | return newDate; 27 | } 28 | 29 | + (nonnull NSDate *)dateByAddingDays:(NSInteger)days toDate:(nonnull NSDate *)date { 30 | NSDateComponents *const dateComponent = [[NSDateComponents alloc] init]; 31 | [dateComponent setDay:days]; 32 | NSCalendar *const calendar = [NSCalendar currentCalendar]; 33 | NSDate *const newDate = [calendar dateByAddingComponents:dateComponent toDate:date options:0]; 34 | if (!newDate) { 35 | TCN_ASSERT_FAILURE(@"no new date found after adding days to the date"); 36 | return date; 37 | } 38 | return newDate; 39 | } 40 | 41 | + (nonnull NSDate *)dateByAddingWeeks:(NSInteger)weeks toDate:(nonnull NSDate *)date { 42 | NSCalendar *const calendar = [NSCalendar currentCalendar]; 43 | NSDateComponents *const components = [[NSDateComponents alloc] init]; 44 | components.weekOfYear = weeks; 45 | NSDate *const newDate = [calendar dateByAddingComponents:components toDate:date options:0]; 46 | if (!newDate) { 47 | TCN_ASSERT_FAILURE(@"no new date found after adding days to the date"); 48 | return date; 49 | } 50 | return newDate; 51 | } 52 | 53 | + (nonnull NSDate *)middleOfWeekForDate:(nonnull NSDate *)date { 54 | NSDate* newDate = [[NSCalendar currentCalendar] dateBySettingUnit:NSCalendarUnitWeekday value:4 ofDate:date options:0]; 55 | if (!newDate) { 56 | return [NSDate date]; 57 | } 58 | return newDate; 59 | } 60 | 61 | + (nonnull NSDate *)endOfDayForDate:(nonnull NSDate *)date { 62 | NSDateComponents *components = [[NSDateComponents alloc] init]; 63 | components.day = 1; 64 | components.second = -1; 65 | NSCalendar *calendar = [NSCalendar currentCalendar]; 66 | NSDate *newDate = [calendar dateByAddingComponents:components toDate:[calendar startOfDayForDate:date] options:0]; 67 | if (!newDate) { 68 | return [NSDate date]; 69 | } 70 | return newDate; 71 | } 72 | 73 | #pragma mark - Date comparison 74 | 75 | + (BOOL)isDate:(nonnull NSDate *)date inSameDayAsDate:(nonnull NSDate *)otherDate { 76 | return [[NSCalendar currentCalendar] isDate:date inSameDayAsDate:otherDate]; 77 | } 78 | 79 | + (BOOL)isDate:(nonnull NSDate *)date inSameWeekAsDate:(nonnull NSDate *)otherDate { 80 | return [[NSCalendar currentCalendar] isDate:date equalToDate:otherDate toUnitGranularity:NSCalendarUnitWeekOfYear]; 81 | } 82 | 83 | + (BOOL)date:(nonnull NSDate *)date isAfterDate:(nonnull NSDate *)otherDate { 84 | return [date compare:otherDate] == NSOrderedDescending; 85 | } 86 | 87 | + (BOOL)date:(nonnull NSDate *)date isBeforeDate:(nonnull NSDate *)otherDate { 88 | return [date compare:otherDate] == NSOrderedAscending; 89 | } 90 | 91 | + (nonnull NSDate *)latestDate:(nonnull NSDate *)date otherDate:(nonnull NSDate *)otherDate { 92 | return [TCNDateUtil date:date isAfterDate:otherDate] ? date : otherDate; 93 | } 94 | 95 | + (nonnull NSDate *)earliestDate:(nonnull NSDate *)date otherDate:(nonnull NSDate *)otherDate { 96 | return [TCNDateUtil date:date isBeforeDate:otherDate] ? date : otherDate; 97 | } 98 | 99 | #pragma mark - Other 100 | 101 | + (NSInteger)indexOfDateInWeek:(nonnull NSDate *)date { 102 | NSCalendar *calendar = [NSCalendar currentCalendar]; 103 | NSInteger weekdayToAdd = (NSInteger)calendar.firstWeekday - 1; 104 | return [calendar component:NSCalendarUnitWeekday fromDate:date] - 1 - weekdayToAdd; 105 | } 106 | 107 | + (nonnull NSArray *)daysOfWeekFromDate:(nonnull NSDate *)date { 108 | NSCalendar *const calendar = [NSCalendar currentCalendar]; 109 | [calendar setLocale:[NSLocale currentLocale]]; 110 | NSDateComponents *const components = [calendar components:(NSCalendarUnitYear | 111 | NSCalendarUnitMonth | 112 | NSCalendarUnitWeekOfYear | 113 | NSCalendarUnitWeekday | 114 | NSCalendarUnitHour | 115 | NSCalendarUnitMinute) 116 | fromDate:date]; 117 | NSMutableArray *const daysOfWeekDates = [[NSMutableArray alloc] init]; 118 | 119 | // We need to account for areas where firstWeekday == 2 (i.e. the week begins on Monday). 120 | NSUInteger weekdayToAdd = calendar.firstWeekday - 1; 121 | for (NSUInteger index = 1; index <= 7; index++) { 122 | [components setWeekday:(index + weekdayToAdd) % 7]; 123 | NSDate *const dateFromComponent = [calendar dateFromComponents:components]; 124 | if (dateFromComponent != nil) { 125 | [daysOfWeekDates addObject:dateFromComponent]; 126 | } 127 | } 128 | return daysOfWeekDates; 129 | } 130 | 131 | @end 132 | -------------------------------------------------------------------------------- /Tachyon/DataSources/TCNDatePickerDataSource.m: -------------------------------------------------------------------------------- 1 | #import "TCNDatePickerDataSource.h" 2 | #import "TCNDatePickerDayView.h" 3 | #import "TCNMacros.h" 4 | #import "TCNDateUtil.h" 5 | #import "TCNViewUtils.h" 6 | 7 | @interface TCNDatePickerDataSource () 8 | 9 | @property (nonatomic, strong, nonnull, readwrite) NSArray *previousWeekDates; 10 | @property (nonatomic, strong, nonnull, readwrite) NSArray *activeWeekDates; 11 | @property (nonatomic, strong, nonnull, readwrite) NSArray *nextWeekDates; 12 | 13 | @property (nonatomic, strong, nonnull, readonly) TCNDatePickerConfig *config; 14 | 15 | @end 16 | 17 | @implementation TCNDatePickerDataSource 18 | 19 | static const int DaysInAWeek = 7; 20 | 21 | #pragma mark - Initialization 22 | 23 | - (nonnull instancetype)initWithConfig:(nonnull TCNDatePickerConfig *)config { 24 | self = [super init]; 25 | if (!self) { 26 | return nil; 27 | } 28 | 29 | _config = config; 30 | _selectedDate = [NSDate date]; 31 | _activeWeekDates = @[]; 32 | _nextWeekDates = @[]; 33 | _previousWeekDates = @[]; 34 | return self; 35 | } 36 | 37 | #pragma mark - Static Helpers 38 | 39 | + (NSInteger)datePickerSectionPreviousWeek { 40 | return [TCNViewUtils isLayoutDirectionRTL] ? 2 : 0; 41 | } 42 | 43 | + (NSInteger)datePickerSectionActiveWeek { 44 | return 1; 45 | } 46 | 47 | + (NSInteger)datePickerSectionNextWeek { 48 | return [TCNViewUtils isLayoutDirectionRTL] ? 0 : 2; 49 | } 50 | 51 | #pragma mark - Methods 52 | 53 | - (void)setupWeekDatesWithCurrentlyVisibleDate:(nonnull NSDate *)date { 54 | NSDate *const activeWeekDate = [TCNDateUtil dateByAddingWeeks:0 toDate:date]; 55 | self.activeWeekDates = [TCNDateUtil daysOfWeekFromDate:activeWeekDate]; 56 | 57 | NSDate *const nextWeekDate = [TCNDateUtil dateByAddingWeeks:1 toDate:date]; 58 | self.nextWeekDates = [TCNDateUtil daysOfWeekFromDate:nextWeekDate]; 59 | 60 | NSDate *const previousWeekDate = [TCNDateUtil dateByAddingWeeks:-1 toDate:date]; 61 | self.previousWeekDates = [TCNDateUtil daysOfWeekFromDate:previousWeekDate]; 62 | } 63 | 64 | - (nullable NSIndexPath *)indexPathForDate:(nonnull NSDate *)date { 65 | if (self.previousWeekDates.count != DaysInAWeek || self.activeWeekDates.count != DaysInAWeek || self.nextWeekDates.count != DaysInAWeek) { 66 | TCN_ASSERT_FAILURE(@"selectedDateIndexPath is called before dates are setup for past, active and next week"); 67 | return nil; 68 | } 69 | 70 | NSInteger row = [TCNDateUtil indexOfDateInWeek:date]; 71 | 72 | // figure out where the selectedDate is currently relative to our past/active/next week 73 | NSInteger section; 74 | 75 | if ([TCNDateUtil isDate:date inSameWeekAsDate:self.previousWeekDates[0]]) { 76 | section = TCNDatePickerDataSource.datePickerSectionPreviousWeek; 77 | } else if ([TCNDateUtil isDate:date inSameWeekAsDate:self.activeWeekDates[0]]) { 78 | section = TCNDatePickerDataSource.datePickerSectionActiveWeek; 79 | } else if ([TCNDateUtil isDate:date inSameWeekAsDate:self.nextWeekDates[0]]) { 80 | section = TCNDatePickerDataSource.datePickerSectionNextWeek; 81 | } else { 82 | section = NSNotFound; 83 | } 84 | 85 | // we haven't found the selected date among past/active/next week dates, so we don't know the index 86 | if (section == NSNotFound) { 87 | return nil; 88 | } 89 | 90 | return [NSIndexPath indexPathForRow:row inSection:section]; 91 | } 92 | 93 | - (nullable NSDate *)dateForItemAtIndexPath:(nonnull NSIndexPath *)indexPath { 94 | if (indexPath.section == TCNDatePickerDataSource.datePickerSectionPreviousWeek) { 95 | return [self.previousWeekDates objectAtIndex:(NSUInteger)indexPath.row]; 96 | } else if (indexPath.section == TCNDatePickerDataSource.datePickerSectionActiveWeek) { 97 | return [self.activeWeekDates objectAtIndex:(NSUInteger)indexPath.row]; 98 | } else if (indexPath.section == TCNDatePickerDataSource.datePickerSectionNextWeek) { 99 | return [self.nextWeekDates objectAtIndex:(NSUInteger)indexPath.row]; 100 | } else { 101 | TCN_ASSERT_FAILURE(@"%ld is not a valid section - only 0, 1, or 2 are valid.", (long)indexPath.section); 102 | return nil; 103 | } 104 | } 105 | 106 | #pragma mark - UICollectionViewDataSource 107 | 108 | - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { 109 | UICollectionViewCell *const collectionViewCell = [collectionView dequeueReusableCellWithReuseIdentifier:TCNDatePickerDayView.reuseIdentifier 110 | forIndexPath:indexPath]; 111 | TCNDatePickerDayView *const dayViewCell = TCN_CAST_OR_NIL(collectionViewCell, TCNDatePickerDayView); 112 | if (!dayViewCell) { 113 | TCN_ASSERT_FAILURE(@"Unable to cast collectionViewCell to expected type of TCNDatePickerDayView"); 114 | return nil; 115 | } 116 | 117 | NSDate *const date = [self dateForItemAtIndexPath:indexPath]; 118 | if (!date) { 119 | TCN_ASSERT_FAILURE(@"No date found for current indexPath"); 120 | return nil; 121 | } 122 | dayViewCell.date = date; 123 | if ([TCNDateUtil isDate:dayViewCell.date inSameDayAsDate:self.selectedDate]) { 124 | [dayViewCell applyStylingFromConfig:self.config selected:YES]; 125 | dayViewCell.selected = YES; 126 | } else { 127 | [dayViewCell applyStylingFromConfig:self.config selected:NO]; 128 | dayViewCell.selected = NO; 129 | } 130 | 131 | return dayViewCell; 132 | } 133 | 134 | - (NSInteger)numberOfSectionsInCollectionView:(__unused UICollectionView *)collectionView { 135 | // Returns 3 for TCNDatePickerSectionPreviousWeek, TCNDatePickerSectionCurrentWeek, and TCNDatePickerSectionNextWeek 136 | return 3; 137 | } 138 | 139 | - (NSInteger)collectionView:(__unused UICollectionView *)collectionView numberOfItemsInSection:(__unused NSInteger)section { 140 | return DaysInAWeek; 141 | } 142 | 143 | @end 144 | -------------------------------------------------------------------------------- /Tachyon/CollectionViewLayouts/TCNAllDayViewLayout.m: -------------------------------------------------------------------------------- 1 | #import "TCNAllDayViewLayout.h" 2 | #import "TCNDayViewLayout+Protected.h" 3 | #import "TCNNumberHelper.h" 4 | #import "TCNDayViewTimeView.h" 5 | #import "TCNDecorationViewLayoutAttributes.h" 6 | #import "TCNDayViewGridlineView.h" 7 | #import "TCNEventCell.h" 8 | 9 | @implementation TCNAllDayViewLayout 10 | 11 | static const CGFloat TimeViewWidth = 56.0f; 12 | static const CGFloat AllDayViewMaximumHeight = 62.0f; 13 | static const CGFloat AllDayViewCellHeight = 22.0f; 14 | static const CGFloat AllDayViewVerticalPadding = 2.0f; 15 | static const UIEdgeInsets AllDayViewCellMargin = {2.0f, 0.0f, 2.0f, 2.0f}; 16 | 17 | #pragma mark - Class helpers 18 | 19 | + (CGFloat)allDayViewHeightForEventCount:(NSInteger)eventCount { 20 | CGFloat contentHeight = [self requiredAllDayViewContentHeightForEventCount:eventCount]; 21 | return MIN(contentHeight, AllDayViewMaximumHeight); 22 | } 23 | 24 | + (CGFloat)requiredAllDayViewContentHeightForEventCount:(NSInteger)eventCount { 25 | return (2 * AllDayViewVerticalPadding) + [self heightForNumberOfAllDayEvents:eventCount]; 26 | } 27 | 28 | + (CGFloat)heightForNumberOfAllDayEvents:(NSInteger)eventCount { 29 | const NSInteger numberOfVerticalEvents = (NSInteger)[TCNNumberHelper ceil:((CGFloat)eventCount) / 2]; 30 | return numberOfVerticalEvents * (AllDayViewCellHeight + AllDayViewCellMargin.top + AllDayViewCellMargin.bottom); 31 | } 32 | 33 | #pragma mark - UICollectionViewLayout 34 | 35 | - (nonnull UICollectionViewLayoutAttributes *)prepareLayoutForDarkGridlineWithIndexPath:(nonnull NSIndexPath *)indexPath 36 | calendarGridWidth:(__unused CGFloat)calendarGridWidth 37 | calendarGridMinX:(__unused CGFloat)calendarGridMinX 38 | calendarGridMinY:(__unused CGFloat)calendarGridMinY { 39 | TCNDecorationViewLayoutAttributes *const horizontalGridlineAttributes = 40 | [TCNDecorationViewLayoutAttributes layoutAttributesForDecorationViewOfKind:TCNDayViewGridlineView.darkKind 41 | withIndexPath:indexPath]; 42 | horizontalGridlineAttributes.frame = CGRectZero; 43 | return horizontalGridlineAttributes; 44 | } 45 | 46 | - (nonnull UICollectionViewLayoutAttributes *)prepareLayoutForLightGridlineWithIndexPath:(nonnull NSIndexPath *__unused)indexPath 47 | calendarGridWidth:(__unused CGFloat)calendarGridWidth 48 | calendarGridMinX:(__unused CGFloat)calendarGridMinX 49 | calendarGridMinY:(__unused CGFloat)calendarGridMinY { 50 | TCNDecorationViewLayoutAttributes *const horizontalLightGridlineAttributes = 51 | [TCNDecorationViewLayoutAttributes layoutAttributesForDecorationViewOfKind:TCNDayViewGridlineView.lightKind 52 | withIndexPath:indexPath]; 53 | horizontalLightGridlineAttributes.frame = CGRectZero; 54 | return horizontalLightGridlineAttributes; 55 | } 56 | 57 | - (nullable UICollectionViewLayoutAttributes *)prepareLayoutForTimeViewWithIndexPath:(nonnull NSIndexPath *)indexPath 58 | sectionMinX:(CGFloat)sectionMinX 59 | calendarGridMinY:(__unused CGFloat)calendarGridMinY { 60 | UICollectionViewLayoutAttributes *const timeViewAttributes = 61 | [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:TCNDayViewTimeView.reuseIdentifier 62 | withIndexPath:indexPath]; 63 | if (indexPath.row == 0) { 64 | timeViewAttributes.frame = CGRectMake(sectionMinX, AllDayViewVerticalPadding + AllDayViewCellMargin.top, TimeViewWidth, AllDayViewCellHeight); 65 | return timeViewAttributes; 66 | } else { 67 | return timeViewAttributes; 68 | } 69 | } 70 | 71 | - (nonnull UICollectionViewLayoutAttributes *)prepareLayoutForEventItemsAtIndexPath:(nonnull NSIndexPath *)indexPath 72 | calendarGridMinX:(CGFloat)calendarGridMinX 73 | calendarGridMinY:(__unused CGFloat)calendarGridMinY 74 | calendarGridMaxX:(CGFloat)calendarGridMaxX { 75 | UICollectionViewLayoutAttributes *const itemAttributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath]; 76 | 77 | const CGFloat heightOfPreviousRow = [TCNAllDayViewLayout heightForNumberOfAllDayEvents:MAX(0, indexPath.row - 1)]; 78 | const CGFloat itemMinY = AllDayViewVerticalPadding + heightOfPreviousRow + AllDayViewCellMargin.top; 79 | const CGFloat itemMinX = calendarGridMinX + AllDayViewCellMargin.left; 80 | const CGFloat itemWidth = calendarGridMaxX - calendarGridMinX - AllDayViewCellMargin.left - AllDayViewCellMargin.right; 81 | itemAttributes.frame = CGRectMake(itemMinX, itemMinY, itemWidth, AllDayViewCellHeight); 82 | itemAttributes.zIndex = [self zIndexForElementKind:TCNEventCell.reuseIdentifier]; 83 | 84 | return itemAttributes; 85 | } 86 | 87 | - (CGSize)collectionViewContentSize { 88 | const NSInteger eventCount = [self.collectionView numberOfItemsInSection:0]; 89 | const CGFloat height = [TCNAllDayViewLayout requiredAllDayViewContentHeightForEventCount:eventCount]; 90 | return CGSizeMake(self.collectionView.frame.size.width, height); 91 | } 92 | 93 | - (nullable UICollectionViewLayoutAttributes *)layoutAttributesForSupplementaryViewOfKind:(NSString *)elementKind atIndexPath:(NSIndexPath *)indexPath { 94 | if (indexPath.row > 0) { 95 | return nil; 96 | } 97 | return [super layoutAttributesForSupplementaryViewOfKind:elementKind atIndexPath:indexPath]; 98 | } 99 | 100 | @end 101 | -------------------------------------------------------------------------------- /Tachyon/Views/TCNEventCell.m: -------------------------------------------------------------------------------- 1 | #import "TCNEventCell.h" 2 | #import "TCNViewUtils.h" 3 | 4 | @interface TCNEventCell () 5 | 6 | @property (nonatomic, strong, nonnull, readonly) UILabel *titleLabel; 7 | @property (nonatomic, strong, nonnull, readonly) UILabel *timeLabel; 8 | @property (nonatomic, strong, nonnull, readonly) UIButton *cancelButton; 9 | @property (nonatomic, strong, nonnull, readonly) CAShapeLayer *roundedCornerMask; 10 | 11 | /** 12 | If true, this event will display as a single line without a time label. 13 | Default NO. 14 | */ 15 | @property (nonatomic, assign, readwrite) BOOL useCompactDisplay; 16 | 17 | @end 18 | 19 | @implementation TCNEventCell 20 | 21 | static const CGFloat SidePadding = 8.0f; 22 | static const CGFloat TopPadding = 4.0f; 23 | static const CGFloat CancelButtonDimension = 48.0f; 24 | static const CGFloat CancelButtonImageDimension = 16.0f; 25 | static const NSInteger CornerRadius = 2.0f; 26 | 27 | + (nonnull NSString *)reuseIdentifier { 28 | return NSStringFromClass([TCNEventCell class]); 29 | } 30 | 31 | #pragma mark - Initialization 32 | 33 | - (instancetype)initWithFrame:(CGRect)frame { 34 | self = [super initWithFrame:frame]; 35 | if (!self) { 36 | return nil; 37 | } 38 | 39 | _useCompactDisplay = NO; 40 | _titleLabel = [TCNEventCell labelWithSuperview:self]; 41 | _timeLabel = [TCNEventCell labelWithSuperview:self]; 42 | _cancelButton = [TCNEventCell cancelButtonWithSuperview:self]; 43 | [TCNEventCell configureBaseView:self]; 44 | 45 | return self; 46 | } 47 | 48 | - (void)setUseCompactDisplay:(BOOL)useCompactDisplay { 49 | _useCompactDisplay = useCompactDisplay; 50 | self.titleLabel.numberOfLines = useCompactDisplay ? 1 : 0; 51 | self.timeLabel.numberOfLines = useCompactDisplay ? 1 : 0; 52 | } 53 | 54 | #pragma mark - Class helpers 55 | 56 | + (nonnull UILabel *)labelWithSuperview:(nonnull UICollectionViewCell *)superview { 57 | UILabel *const label = [[UILabel alloc] init]; 58 | label.textAlignment = NSTextAlignmentNatural; 59 | label.lineBreakMode = NSLineBreakByTruncatingTail; 60 | [superview.contentView addSubview:label]; 61 | return label; 62 | } 63 | 64 | + (nonnull UIButton *)cancelButtonWithSuperview:(nonnull UICollectionViewCell *)superview { 65 | UIButton *const button = [[UIButton alloc] init]; 66 | [superview.contentView addSubview:button]; 67 | return button; 68 | } 69 | 70 | + (void)configureBaseView:(nonnull UIView *)view { 71 | view.layer.masksToBounds = YES; 72 | } 73 | 74 | #pragma mark - View lifecycle 75 | 76 | - (void)layoutSubviews { 77 | [super layoutSubviews]; 78 | 79 | self.layer.cornerRadius = CornerRadius; 80 | 81 | self.titleLabel.frame = CGRectMake( 82 | SidePadding, 83 | TopPadding, 84 | self.bounds.size.width - (2 * SidePadding), 85 | self.bounds.size.height - (2 * TopPadding)); 86 | 87 | if (!self.useCompactDisplay) { 88 | [self.titleLabel sizeToFit]; 89 | 90 | // We can't allow the titleLabel's frame to exceed that of the cell itself, or it will not truncate correctly. 91 | self.titleLabel.frame = CGRectMake( 92 | self.titleLabel.frame.origin.x, 93 | self.titleLabel.frame.origin.y, 94 | self.titleLabel.frame.size.width, 95 | MIN(self.frame.size.height, self.titleLabel.frame.size.height)); 96 | } 97 | 98 | if (self.useCompactDisplay) { 99 | self.timeLabel.frame = CGRectZero; 100 | } else { 101 | const CGFloat titleLabelMaxY = TopPadding + self.titleLabel.frame.size.height; 102 | self.timeLabel.frame = CGRectMake( 103 | SidePadding, 104 | titleLabelMaxY, 105 | self.bounds.size.width - (2 * SidePadding), 106 | self.timeLabel.font.lineHeight); 107 | } 108 | 109 | const CGFloat insetDimension = CancelButtonDimension - CancelButtonImageDimension; 110 | 111 | if (self.cancelButton.currentImage) { 112 | self.cancelButton.frame = CGRectMake( 113 | self.bounds.size.width - SidePadding - insetDimension, 114 | (self.bounds.size.height - CancelButtonDimension) / 2, 115 | CancelButtonDimension, 116 | CancelButtonDimension); 117 | 118 | self.cancelButton.imageEdgeInsets = UIEdgeInsetsMake(insetDimension, insetDimension, insetDimension, insetDimension); 119 | } 120 | 121 | [TCNViewUtils layoutSubviewsForRTL:self]; 122 | } 123 | 124 | - (void)prepareForReuse { 125 | [super prepareForReuse]; 126 | 127 | self.titleLabel.text = @""; 128 | self.timeLabel.text = @""; 129 | } 130 | 131 | - (void)updateWithEvent:(nonnull TCNEvent *)event { 132 | self.titleLabel.text = event.name; 133 | self.timeLabel.text = event.displayTimeString; 134 | self.useCompactDisplay = event.isAllDay; 135 | 136 | [self setNeedsLayout]; 137 | } 138 | 139 | #pragma mark - Methods and Property Overrides 140 | 141 | - (void)setCancelHandler:(void (^_Nullable)(void))cancelHandler { 142 | _cancelHandler = cancelHandler; 143 | if (cancelHandler) { 144 | [self.cancelButton addTarget:self action:@selector(cancelButtonTapped) forControlEvents:UIControlEventTouchUpInside]; 145 | } 146 | } 147 | 148 | - (void)cancelButtonTapped { 149 | if (self.cancelHandler) { 150 | self.cancelHandler(); 151 | } 152 | } 153 | 154 | # pragma mark - TCNDayViewConfigurable 155 | 156 | - (void)applyStylingFromConfig:(nonnull TCNDayViewConfig *)config selected:(BOOL)selected { 157 | self.contentView.backgroundColor = selected ? config.selectedEventColor : config.eventColor; 158 | self.titleLabel.textColor = selected ? config.selectedEventTextColor : config.eventTextColor; 159 | self.titleLabel.font = config.eventFont; 160 | 161 | self.timeLabel.textColor = selected ? config.selectedEventTextColor : config.eventTextColor; 162 | self.timeLabel.font = config.eventFont; 163 | 164 | self.cancelButton.tintColor = config.selectedEventTextColor; 165 | 166 | self.timeLabel.hidden = !selected; 167 | self.cancelButton.hidden = !(selected && config.shouldShowCancelButtonOnCreatedEvents); 168 | 169 | UIImage *const image = [config.cancelButtonImage imageWithRenderingMode:(UIImageRenderingModeAlwaysTemplate)]; 170 | if (image) { 171 | [self.cancelButton setImage:image forState:UIControlStateNormal]; 172 | } 173 | } 174 | 175 | @end 176 | -------------------------------------------------------------------------------- /TachyonSampleApp/ViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ViewController: UIViewController { 4 | 5 | // MARK: - Static 6 | 7 | private static let lightGrayBackgroundColor = UIColor(displayP3Red: 225/255, green: 223/255, blue: 238/255, alpha: 1.0) 8 | 9 | /** 10 | The view config for our date picker. 11 | */ 12 | private static var datePickerConfig: TCNDatePickerConfig { 13 | let config = TCNDatePickerConfig() 14 | config.selectedColor = .blue 15 | config.backgroundColor = lightGrayBackgroundColor 16 | config.weekendTextColor = .lightGray 17 | return config 18 | } 19 | 20 | /** 21 | The view config for our day view. 22 | */ 23 | private static var dayViewConfig: TCNDayViewConfig { 24 | let config = TCNDayViewConfig() 25 | config.selectedEventColor = .blue 26 | config.selectedEventTextColor = .white 27 | config.cancelButtonImage = UIImage(named: "ic_cancel_16dp") 28 | config.customAllDayViewConfig = { (view) in 29 | view.layer.borderColor = lightGrayBackgroundColor.cgColor 30 | view.layer.borderWidth = 1.0 31 | } 32 | return config 33 | } 34 | 35 | // MARK: - Properties 36 | 37 | var currentDate: Date = Date() 38 | 39 | private var createdEvents: [TCNEvent] = [] 40 | private let datePicker: TCNDatePickerView = TCNDatePickerView(frame: CGRect.zero, config: ViewController.datePickerConfig) 41 | private let dayView: TCNDayView = TCNDayView(frame: CGRect.zero, config: ViewController.dayViewConfig) 42 | 43 | /** 44 | Returns the view's frame accounting for its `safeAreaInsets`. 45 | */ 46 | private var viewSafeAreaRect: CGRect { 47 | return CGRect( 48 | x: view.safeAreaInsets.left, 49 | y: view.safeAreaInsets.top, 50 | width: view.frame.width - view.safeAreaInsets.left - view.safeAreaInsets.right, 51 | height: view.frame.height - view.safeAreaInsets.bottom - view.safeAreaInsets.top) 52 | } 53 | 54 | // MARK: - View Lifecycle 55 | 56 | override func viewDidLoad() { 57 | super.viewDidLoad() 58 | 59 | datePicker.datePickerDelegate = self 60 | view.addSubview(datePicker) 61 | 62 | // update day view 63 | dayView.delegate = self; 64 | dayView.dataSource = self; 65 | view.addSubview(dayView) 66 | 67 | NotificationCenter.default.addObserver( 68 | self, 69 | selector: #selector(applicationDidBecomeActive), 70 | name: UIApplication.didBecomeActiveNotification, 71 | object: nil) 72 | } 73 | 74 | override func viewDidAppear(_ animated: Bool) { 75 | super.viewDidAppear(animated) 76 | 77 | // Set the current date 78 | dayView.reload(resetScrolling: true) 79 | } 80 | 81 | override func viewWillLayoutSubviews() { 82 | super.viewWillLayoutSubviews() 83 | 84 | datePicker.frame = CGRect( 85 | x: viewSafeAreaRect.minX, 86 | y: viewSafeAreaRect.minY, 87 | width: viewSafeAreaRect.width, 88 | height: TCNDatePickerView.heightRequired(for: ViewController.datePickerConfig)) 89 | 90 | dayView.frame = CGRect( 91 | x: viewSafeAreaRect.minX, 92 | y: datePicker.frame.maxY, 93 | width: viewSafeAreaRect.width, 94 | height: viewSafeAreaRect.height - datePicker.frame.height) 95 | } 96 | 97 | /** 98 | Called when the application comes into the foreground. 99 | Resets the `ViewController` state. 100 | */ 101 | @objc 102 | private func applicationDidBecomeActive() { 103 | currentDate = Date() 104 | createdEvents = [] 105 | } 106 | 107 | } 108 | 109 | // MARK: - TCNDatePickerDelegate 110 | 111 | extension ViewController: TCNDatePickerDelegate { 112 | 113 | func datePickerView(_ datePickerView: TCNDatePickerView, didSelect date: Date) { 114 | currentDate = date 115 | 116 | // When a new date is selected, we'll update the dayView. 117 | dayView.reload(resetScrolling: false) 118 | } 119 | 120 | } 121 | 122 | // MARK: - TCNDayViewDelegate 123 | 124 | extension ViewController: TCNDayViewDelegate { 125 | 126 | func dayView(_ dayView: TCNDayView, didSelectAvailabilityWith event: TCNEvent) { 127 | if createdEvents.contains(where: { $0.startDateTime == event.startDateTime }) { 128 | return 129 | } 130 | createdEvents.append(event) 131 | dayView.reload(resetScrolling: false) 132 | 133 | print("Selected time: \(ViewController.displayText(for: event))") 134 | } 135 | 136 | func dayView(_ dayView: TCNDayView, didCancel event: TCNEvent) { 137 | createdEvents.removeAll { (createdEvent) -> Bool in 138 | event == createdEvent 139 | } 140 | dayView.reload(resetScrolling: false) 141 | } 142 | 143 | private static func displayText(for event: TCNEvent) -> String { 144 | let dateFormatter = DateFormatter() 145 | dateFormatter.locale = Locale.current 146 | dateFormatter.setLocalizedDateFormatFromTemplate("EEEE, d MMM yyyy") 147 | let dateString = dateFormatter.string(from: event.startDateTime) 148 | 149 | let timeFormatter = DateFormatter() 150 | timeFormatter.locale = Locale.current 151 | timeFormatter.timeStyle = .short 152 | let startTimeString = timeFormatter.string(from: event.startDateTime) 153 | let endTimeString = timeFormatter.string(from: event.endDateTime) 154 | 155 | let timeZoneString = event.timezone?.localizedName(for: .shortStandard, locale: Locale.current) 156 | ?? TimeZone.current.localizedName(for: .shortStandard, locale: Locale.current) 157 | ?? "" 158 | return "\(dateString) from \(startTimeString) to \(endTimeString) (\(timeZoneString))" 159 | } 160 | 161 | } 162 | 163 | // MARK: - TCNDayViewDataSource 164 | 165 | extension ViewController: TCNDayViewDataSource { 166 | 167 | var dayEvents: [TCNEvent] { 168 | return (getSampleEvents(for: currentDate) + createdEvents(for: currentDate)).filter { !$0.isAllDay } 169 | } 170 | 171 | var allDayEvents: [TCNEvent] { 172 | return (getSampleEvents(for: currentDate) + createdEvents(for: currentDate)).filter { $0.isAllDay } 173 | } 174 | 175 | private func createdEvents(for date: Date) -> [TCNEvent] { 176 | return createdEvents.filter { $0.occurs(onDay: date) } 177 | } 178 | 179 | /** 180 | Let's return a few different event sets for each day of the week. 181 | 182 | This only works for the Gregorian calendar! 183 | */ 184 | private func getSampleEvents(for date: Date) -> [TCNEvent] { 185 | let components = Calendar(identifier: .gregorian).dateComponents(in: TimeZone.current, from: date) 186 | switch (components.weekday) { 187 | case 1: 188 | return SampleEvents.sunday(date) 189 | case 2: 190 | return SampleEvents.monday(date) 191 | case 3: 192 | return SampleEvents.tuesday(date) 193 | case 4: 194 | return SampleEvents.wednesday(date) 195 | case 5: 196 | return SampleEvents.thursday(date) 197 | case 6: 198 | return SampleEvents.friday(date) 199 | case 7: 200 | return SampleEvents.saturday(date) 201 | default: 202 | return [] 203 | } 204 | } 205 | 206 | } 207 | 208 | -------------------------------------------------------------------------------- /TachyonTests/Day View/TCNDayViewTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import 4 | 5 | #import "TCNDayView.h" 6 | #import "TCNDayViewTestsViewProvider.h" 7 | #import "TCNDayViewTimeView.h" 8 | #import "TCNDateUtil.h" 9 | #import "TCNEvent.h" 10 | #import "TCNTestUtils.h" 11 | 12 | @interface TCNDayView (Testing) 13 | 14 | @property (nonatomic, strong, nonnull, readonly) UICollectionView *collectionView; 15 | @property (nonatomic, strong, nonnull, readonly) UICollectionView *allDayCollectionView; 16 | 17 | @end 18 | 19 | /** 20 | This test tests scrollable UICollectionViews. Since LayoutTest does not support scrollable views, we assume that the scrollview 21 | starts with a content offset of zero. Any events added to test with therefore need to be visible within the first screen, 22 | unless a programmatic content offset change is triggered. 23 | 24 | @c TCNDayViewTestsViewProvider.sharedInstance is the dataSource of @c TCNDayView instances created through it. 25 | */ 26 | @interface TCNDayViewTests : LYTLayoutTestCase 27 | 28 | @end 29 | 30 | @implementation TCNDayViewTests 31 | 32 | - (void)tearDown { 33 | [super tearDown]; 34 | 35 | TCNDayViewTestsViewProvider.sharedInstance.events = @[]; 36 | } 37 | 38 | - (void)testEmptyLayout { 39 | [self runLayoutTestsWithViewProvider:TCNDayViewTestsViewProvider.self 40 | validation:^(TCNDayView *_Nonnull view, NSDictionary * _Nonnull data, NSArray * context) { 41 | [self ignoreTopAndBottomTimeViewsOnDayViewCollectionView:view.collectionView]; 42 | }]; 43 | } 44 | 45 | - (void)testEventLayout { 46 | NSDate *const startTime = [TCNTestUtils dateWithTime:@"2:00" onDay:[NSDate date]]; 47 | NSDate *const startTime2 = [TCNTestUtils dateWithTime:@"3:00" onDay:[NSDate date]]; 48 | NSDate *const endTime = [TCNTestUtils dateWithTime:@"13:00" onDay:[NSDate date]]; 49 | NSDate *const endTime2 = [TCNTestUtils dateWithTime:@"14:00" onDay:[NSDate date]]; 50 | TCNDayViewTestsViewProvider.sharedInstance.events = @[ 51 | [[TCNEvent alloc] initWithName:@"Hello!" 52 | startDateTime:startTime 53 | endDateTime:endTime 54 | location:nil 55 | timezone:nil 56 | isAllDay:NO], 57 | [[TCNEvent alloc] initWithName:@"Hello!" 58 | startDateTime:startTime2 59 | endDateTime:endTime 60 | location:nil 61 | timezone:nil 62 | isAllDay:NO], 63 | [[TCNEvent alloc] initWithName:@"Hello!" 64 | startDateTime:startTime 65 | endDateTime:endTime2 66 | location:nil 67 | timezone:nil 68 | isAllDay:NO] 69 | ]; 70 | 71 | [self runLayoutTestsWithViewProvider:TCNDayViewTestsViewProvider.self 72 | validation:^(TCNDayView *_Nonnull view, NSDictionary * _Nonnull data, NSArray * context) { 73 | [view reloadAndResetScrolling:NO]; 74 | [self ignoreTopAndBottomTimeViewsOnDayViewCollectionView:view.collectionView]; 75 | }]; 76 | } 77 | 78 | - (void)testAllDayEventLayout { 79 | TCNDayViewTestsViewProvider.sharedInstance.events = @[ 80 | [[TCNEvent alloc] initWithName:@"Hello!" 81 | startDateTime:[NSDate date] 82 | endDateTime:[NSDate date] 83 | location:nil 84 | timezone:nil 85 | isAllDay:YES], 86 | [[TCNEvent alloc] initWithName:@"Hello!" 87 | startDateTime:[NSDate date] 88 | endDateTime:[NSDate date] 89 | location:nil 90 | timezone:nil 91 | isAllDay:YES], 92 | [[TCNEvent alloc] initWithName:@"Hello!" 93 | startDateTime:[NSDate date] 94 | endDateTime:[NSDate date] 95 | location:nil 96 | timezone:nil 97 | isAllDay:YES] 98 | ]; 99 | 100 | [self runLayoutTestsWithViewProvider:TCNDayViewTestsViewProvider.self 101 | validation:^(TCNDayView *_Nonnull view, NSDictionary * _Nonnull data, NSArray * context) { 102 | [self ignoreAllTimeViewsOnCollectionView:view.allDayCollectionView]; 103 | [self ignoreTopAndBottomTimeViewsOnDayViewCollectionView:view.collectionView]; 104 | }]; 105 | } 106 | 107 | #pragma mark - Helpers 108 | 109 | /** 110 | The topmost and bottommost @c TCNDayViewTimeView views may extend off-screen. Ignore them. 111 | */ 112 | - (void)ignoreTopAndBottomTimeViewsOnDayViewCollectionView:(nonnull UICollectionView *)collectionView { 113 | TCNDayViewTimeView *_Nullable topTimeView; 114 | TCNDayViewTimeView *_Nullable bottomTimeView; 115 | for (UIView *view in collectionView.subviews) { 116 | if (![view isKindOfClass:TCNDayViewTimeView.self]) { 117 | continue; 118 | } 119 | 120 | if (!topTimeView || topTimeView.frame.origin.y > view.frame.origin.y) { 121 | topTimeView = (TCNDayViewTimeView *)view; 122 | } 123 | 124 | if (!bottomTimeView || bottomTimeView.frame.origin.y < view.frame.origin.y) { 125 | bottomTimeView = (TCNDayViewTimeView *)view; 126 | } 127 | } 128 | 129 | if (topTimeView) { 130 | [self.viewsAllowingOverlap addObject:topTimeView]; 131 | } 132 | 133 | if (bottomTimeView) { 134 | [self.viewsAllowingOverlap addObject:bottomTimeView]; 135 | } 136 | } 137 | 138 | /** 139 | For all day collection views, the time views will almost always be truncated. 140 | 141 | The all day collection view also renders some time views with a height of 0. 142 | */ 143 | - (void)ignoreAllTimeViewsOnCollectionView:(nonnull UICollectionView *)collectionView { 144 | for (UIView *view in collectionView.subviews) { 145 | if ([view isKindOfClass:TCNDayViewTimeView.class]) { 146 | [self.viewsAllowingOverlap addObject:view]; 147 | [self recursivelyIgnoreOverlappingSubviewsOnView:view]; 148 | } 149 | } 150 | } 151 | 152 | @end 153 | -------------------------------------------------------------------------------- /TachyonTests/Models/TCNEventTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | class TCNEventTests: XCTestCase { 5 | 6 | func testDisplayTimeString() { 7 | let event1 = TCNEvent( 8 | name: "", 9 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date()), 10 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 11 | location: nil, 12 | timezone: nil, 13 | isAllDay: false) 14 | XCTAssertEqual(event1?.displayTimeString, "12:00 PM - 1:00 PM") 15 | 16 | let event2 = TCNEvent( 17 | name: "", 18 | startDateTime: TCNTestUtils.date(withTime: "1:00", onDay: Date(), daysToAdd: -1), 19 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 20 | location: nil, 21 | timezone: nil, 22 | isAllDay: false) 23 | XCTAssertEqual(event2?.displayTimeString, "1:00 AM - 1:00 PM") 24 | } 25 | 26 | func testOccursOnDay() { 27 | let event1 = TCNEvent( 28 | name: "", 29 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date()), 30 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 31 | location: nil, 32 | timezone: nil, 33 | isAllDay: false) 34 | XCTAssertTrue(event1?.occurs(onDay: Date()) ?? false) 35 | 36 | let event2 = TCNEvent( 37 | name: "", 38 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date(), daysToAdd: -1), 39 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 40 | location: nil, 41 | timezone: nil, 42 | isAllDay: false) 43 | XCTAssertTrue(event2?.occurs(onDay: Date()) ?? false) 44 | 45 | let event3 = TCNEvent( 46 | name: "", 47 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date()), 48 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date(), daysToAdd: 1), 49 | location: nil, 50 | timezone: nil, 51 | isAllDay: false) 52 | XCTAssertTrue(event3?.occurs(onDay: Date()) ?? false) 53 | 54 | let event4 = TCNEvent( 55 | name: "", 56 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date(), daysToAdd: -1), 57 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date(), daysToAdd: 1), 58 | location: nil, 59 | timezone: nil, 60 | isAllDay: false) 61 | XCTAssertTrue(event4?.occurs(onDay: Date()) ?? false) 62 | 63 | let event5 = TCNEvent( 64 | name: "", 65 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date(), daysToAdd: 1), 66 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date(), daysToAdd: 1), 67 | location: nil, 68 | timezone: nil, 69 | isAllDay: false) 70 | XCTAssertFalse(event5?.occurs(onDay: Date()) ?? true) 71 | } 72 | 73 | func testMergedEvents() { 74 | var mergedEvents = TCNEvent.mergedEvents(for: [ 75 | TCNEvent( 76 | name: "", 77 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date()), 78 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 79 | location: nil, 80 | timezone: nil, 81 | isAllDay: false), 82 | TCNEvent( 83 | name: "", 84 | startDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 85 | endDateTime: TCNTestUtils.date(withTime: "14:00", onDay: Date()), 86 | location: nil, 87 | timezone: nil, 88 | isAllDay: false) 89 | ].compactMap { $0 }) 90 | 91 | XCTAssertEqual(mergedEvents.count, 1) 92 | XCTAssertEqual(mergedEvents[0].startDateTime, TCNTestUtils.date(withTime: "12:00", onDay: Date())) 93 | XCTAssertEqual(mergedEvents[0].endDateTime, TCNTestUtils.date(withTime: "14:00", onDay: Date())) 94 | 95 | mergedEvents = TCNEvent.mergedEvents(for: [ 96 | TCNEvent( 97 | name: "", 98 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date()), 99 | endDateTime: TCNTestUtils.date(withTime: "12:30", onDay: Date()), 100 | location: nil, 101 | timezone: nil, 102 | isAllDay: false), 103 | TCNEvent( 104 | name: "", 105 | startDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 106 | endDateTime: TCNTestUtils.date(withTime: "14:00", onDay: Date()), 107 | location: nil, 108 | timezone: nil, 109 | isAllDay: false) 110 | ].compactMap { $0 }) 111 | 112 | XCTAssertEqual(mergedEvents.count, 2) 113 | 114 | mergedEvents = TCNEvent.mergedEvents(for: [ 115 | TCNEvent( 116 | name: "", 117 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date()), 118 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 119 | location: nil, 120 | timezone: nil, 121 | isAllDay: false), 122 | TCNEvent( 123 | name: "", 124 | startDateTime: TCNTestUtils.date(withTime: "12:30", onDay: Date()), 125 | endDateTime: TCNTestUtils.date(withTime: "14:00", onDay: Date()), 126 | location: nil, 127 | timezone: nil, 128 | isAllDay: false) 129 | ].compactMap { $0 }) 130 | 131 | XCTAssertEqual(mergedEvents.count, 1) 132 | XCTAssertEqual(mergedEvents[0].startDateTime, TCNTestUtils.date(withTime: "12:00", onDay: Date())) 133 | XCTAssertEqual(mergedEvents[0].endDateTime, TCNTestUtils.date(withTime: "14:00", onDay: Date())) 134 | 135 | mergedEvents = TCNEvent.mergedEvents(for: [ 136 | TCNEvent( 137 | name: "", 138 | startDateTime: TCNTestUtils.date(withTime: "12:00", onDay: Date()), 139 | endDateTime: TCNTestUtils.date(withTime: "13:00", onDay: Date()), 140 | location: nil, 141 | timezone: nil, 142 | isAllDay: false), 143 | TCNEvent( 144 | name: "", 145 | startDateTime: TCNTestUtils.date(withTime: "12:30", onDay: Date()), 146 | endDateTime: TCNTestUtils.date(withTime: "14:00", onDay: Date()), 147 | location: nil, 148 | timezone: nil, 149 | isAllDay: false), 150 | TCNEvent( 151 | name: "", 152 | startDateTime: TCNTestUtils.date(withTime: "12:30", onDay: Date()), 153 | endDateTime: TCNTestUtils.date(withTime: "16:00", onDay: Date()), 154 | location: nil, 155 | timezone: nil, 156 | isAllDay: false), 157 | TCNEvent( 158 | name: "", 159 | startDateTime: TCNTestUtils.date(withTime: "17:30", onDay: Date()), 160 | endDateTime: TCNTestUtils.date(withTime: "18:00", onDay: Date()), 161 | location: nil, 162 | timezone: nil, 163 | isAllDay: false), 164 | TCNEvent( 165 | name: "", 166 | startDateTime: TCNTestUtils.date(withTime: "18:00", onDay: Date()), 167 | endDateTime: TCNTestUtils.date(withTime: "19:00", onDay: Date()), 168 | location: nil, 169 | timezone: nil, 170 | isAllDay: false), 171 | TCNEvent( 172 | name: "", 173 | startDateTime: TCNTestUtils.date(withTime: "17:00", onDay: Date()), 174 | endDateTime: TCNTestUtils.date(withTime: "20:00", onDay: Date()), 175 | location: nil, 176 | timezone: nil, 177 | isAllDay: false) 178 | ].compactMap { $0 }) 179 | 180 | XCTAssertEqual(mergedEvents.count, 2) 181 | XCTAssertEqual(mergedEvents[0].startDateTime, TCNTestUtils.date(withTime: "12:00", onDay: Date())) 182 | XCTAssertEqual(mergedEvents[0].endDateTime, TCNTestUtils.date(withTime: "16:00", onDay: Date())) 183 | XCTAssertEqual(mergedEvents[1].startDateTime, TCNTestUtils.date(withTime: "17:00", onDay: Date())) 184 | XCTAssertEqual(mergedEvents[1].endDateTime, TCNTestUtils.date(withTime: "20:00", onDay: Date())) 185 | } 186 | 187 | } 188 | -------------------------------------------------------------------------------- /TachyonSampleApp/SampleEvents.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | Contains methods to retrieve a selection of sample events. 5 | */ 6 | enum SampleEvents { 7 | 8 | private static var dateFormatter: DateFormatter = { 9 | let dateFormatter = DateFormatter() 10 | dateFormatter.dateFormat = "YYYY-MM-dd HH:mm" 11 | return dateFormatter 12 | }() 13 | 14 | private static func dateWithTime(_ timeString: String, onDay day: Date, daysToAdd: Int = 0) -> Date { 15 | let time = dateFormatter.date(from: "2019-01-18 \(timeString)") ?? Date() 16 | let calendar = Calendar(identifier: .gregorian) 17 | let timeComponents = calendar.dateComponents(in: TimeZone.current, from: time) 18 | 19 | guard let hour = timeComponents.hour, 20 | let minute = timeComponents.minute, 21 | let second = timeComponents.second else { 22 | return Date() 23 | } 24 | let dayToUse = TCNDateUtil.date(byAddingDays: daysToAdd, to: day) 25 | 26 | return calendar.date(bySettingHour: hour, minute: minute, second: second, of: dayToUse) ?? Date() 27 | } 28 | 29 | /** 30 | Convenience method to coalesce a non-optional TCNEvent. 31 | */ 32 | private static func event(name: String, 33 | startDateTime: Date, 34 | endDateTime: Date, 35 | location: String?, 36 | timezone: TimeZone?, 37 | isAllDay: Bool) -> TCNEvent { 38 | return TCNEvent( 39 | name: name, 40 | startDateTime: startDateTime, 41 | endDateTime: endDateTime, 42 | location: location, 43 | timezone: timezone, 44 | isAllDay: isAllDay) ?? TCNEvent(name: name, startDateTime: startDateTime) 45 | } 46 | 47 | static func sunday(_ date: Date) -> [TCNEvent] { 48 | return [ 49 | event( 50 | name: "Play Kingdom Hearts 1", 51 | startDateTime: dateWithTime("12:00", onDay: date), 52 | endDateTime: dateWithTime("12:00", onDay: date), 53 | location: nil, 54 | timezone: nil, 55 | isAllDay: true), 56 | event( 57 | name: "Play Kingdom Hearts 2", 58 | startDateTime: dateWithTime("12:00", onDay: date), 59 | endDateTime: dateWithTime("12:00", onDay: date), 60 | location: nil, 61 | timezone: nil, 62 | isAllDay: true), 63 | event( 64 | name: "Play Kingdom Hearts Birth By Sleep", 65 | startDateTime: dateWithTime("12:00", onDay: date), 66 | endDateTime: dateWithTime("12:00", onDay: date), 67 | location: nil, 68 | timezone: nil, 69 | isAllDay: true), 70 | event( 71 | name: "Play Kingdom Hearts Dream Drop Distance", 72 | startDateTime: dateWithTime("12:00", onDay: date), 73 | endDateTime: dateWithTime("12:00", onDay: date), 74 | location: nil, 75 | timezone: nil, 76 | isAllDay: true), 77 | event( 78 | name: "Play Kingdom Hearts 3", 79 | startDateTime: dateWithTime("12:00", onDay: date), 80 | endDateTime: dateWithTime("12:00", onDay: date), 81 | location: nil, 82 | timezone: nil, 83 | isAllDay: true) 84 | ] 85 | } 86 | 87 | static func monday(_ date: Date) -> [TCNEvent] { 88 | return [ 89 | event( 90 | name: "Drink coffees numbers 4-6", 91 | startDateTime: dateWithTime("12:00", onDay: date), 92 | endDateTime: dateWithTime("13:00", onDay: date), 93 | location: nil, 94 | timezone: nil, 95 | isAllDay: false), 96 | event( 97 | name: "Meeting", 98 | startDateTime: dateWithTime("12:30", onDay: date), 99 | endDateTime: dateWithTime("14:00", onDay: date), 100 | location: nil, 101 | timezone: nil, 102 | isAllDay: false), 103 | event( 104 | name: "Offsite!", 105 | startDateTime: dateWithTime("12:30", onDay: date), 106 | endDateTime: dateWithTime("06:00", onDay: date, daysToAdd: 1), 107 | location: nil, 108 | timezone: nil, 109 | isAllDay: false), 110 | event( 111 | name: "Fly in from Bora Bora", 112 | startDateTime: dateWithTime("8:30", onDay: date), 113 | endDateTime: dateWithTime("09:00", onDay: date), 114 | location: nil, 115 | timezone: nil, 116 | isAllDay: false) 117 | ] 118 | } 119 | 120 | static func tuesday(_ date: Date) -> [TCNEvent] { 121 | return [ 122 | event( 123 | name: "Offsite!", 124 | startDateTime: dateWithTime("12:30", onDay: date, daysToAdd: -1), 125 | endDateTime: dateWithTime("06:00", onDay: date), 126 | location: nil, 127 | timezone: nil, 128 | isAllDay: false), 129 | event( 130 | name: "Lounge", 131 | startDateTime: dateWithTime("8:30", onDay: date), 132 | endDateTime: dateWithTime("09:00", onDay: date), 133 | location: nil, 134 | timezone: nil, 135 | isAllDay: false), 136 | event( 137 | name: "Work", 138 | startDateTime: dateWithTime("9:30", onDay: date), 139 | endDateTime: dateWithTime("17:00", onDay: date), 140 | location: nil, 141 | timezone: nil, 142 | isAllDay: false) 143 | ] 144 | } 145 | 146 | static func wednesday(_ date: Date) -> [TCNEvent] { 147 | return [ 148 | event( 149 | name: "Eat breakfast cookies", 150 | startDateTime: dateWithTime("8:00", onDay: date), 151 | endDateTime: dateWithTime("9:00", onDay: date), 152 | location: nil, 153 | timezone: nil, 154 | isAllDay: false), 155 | event( 156 | name: "Eat lunch cereal", 157 | startDateTime: dateWithTime("12:30", onDay: date), 158 | endDateTime: dateWithTime("13:00", onDay: date), 159 | location: nil, 160 | timezone: nil, 161 | isAllDay: false), 162 | event( 163 | name: "Eat dinner potato chips", 164 | startDateTime: dateWithTime("18:00", onDay: date), 165 | endDateTime: dateWithTime("19:00", onDay: date), 166 | location: nil, 167 | timezone: nil, 168 | isAllDay: false), 169 | event( 170 | name: "Don't eat midnight snacks!", 171 | startDateTime: dateWithTime("18:00", onDay:date, daysToAdd: -1), 172 | endDateTime: dateWithTime("19:00", onDay: date, daysToAdd: -1), 173 | location: nil, 174 | timezone: nil, 175 | isAllDay: false) 176 | ] 177 | } 178 | 179 | static func thursday(_ date: Date) -> [TCNEvent] { 180 | return [ 181 | event( 182 | name: "Go shopping for that titanium toothbrush stand you've always wanted. You deserve it!", 183 | startDateTime: dateWithTime("13:30", onDay: date), 184 | endDateTime: dateWithTime("16:00", onDay: date), 185 | location: nil, 186 | timezone: nil, 187 | isAllDay: false), 188 | event( 189 | name: "Silent dance party", 190 | startDateTime: dateWithTime("16:00", onDay: date), 191 | endDateTime: dateWithTime("19:00", onDay: date), 192 | location: nil, 193 | timezone: nil, 194 | isAllDay: false) 195 | ] 196 | } 197 | 198 | static func friday(_ date: Date) -> [TCNEvent] { 199 | return [ 200 | event( 201 | name: "Beer", 202 | startDateTime: dateWithTime("08:00", onDay: date), 203 | endDateTime: dateWithTime("10:00", onDay: date), 204 | location: nil, 205 | timezone: nil, 206 | isAllDay: false), 207 | event( 208 | name: "Beer", 209 | startDateTime: dateWithTime("10:00", onDay: date), 210 | endDateTime: dateWithTime("12:00", onDay: date), 211 | location: nil, 212 | timezone: nil, 213 | isAllDay: false), 214 | event( 215 | name: "Beer", 216 | startDateTime: dateWithTime("12:00", onDay: date), 217 | endDateTime: dateWithTime("14:00", onDay: date), 218 | location: nil, 219 | timezone: nil, 220 | isAllDay: false), 221 | event( 222 | name: "Beer", 223 | startDateTime: dateWithTime("14:00", onDay: date), 224 | endDateTime: dateWithTime("16:00", onDay: date), 225 | location: nil, 226 | timezone: nil, 227 | isAllDay: false), 228 | event( 229 | name: "Beer", 230 | startDateTime: dateWithTime("16:00", onDay: date), 231 | endDateTime: dateWithTime("18:00", onDay: date), 232 | location: nil, 233 | timezone: nil, 234 | isAllDay: false), 235 | event( 236 | name: "Beer", 237 | startDateTime: dateWithTime("18:00", onDay: date), 238 | endDateTime: dateWithTime("20:00", onDay: date), 239 | location: nil, 240 | timezone: nil, 241 | isAllDay: false), 242 | event( 243 | name: "Cognac", 244 | startDateTime: dateWithTime("20:00", onDay: date), 245 | endDateTime: dateWithTime("22:00", onDay: date), 246 | location: nil, 247 | timezone: nil, 248 | isAllDay: false) 249 | ] 250 | } 251 | 252 | static func saturday(_ date: Date) -> [TCNEvent] { 253 | return [ 254 | event( 255 | name: "Think", 256 | startDateTime: dateWithTime("12:00", onDay: date), 257 | endDateTime: dateWithTime("12:00", onDay: date), 258 | location: nil, 259 | timezone: nil, 260 | isAllDay: true), 261 | event( 262 | name: "Discuss", 263 | startDateTime: dateWithTime("12:00", onDay: date), 264 | endDateTime: dateWithTime("12:00", onDay: date), 265 | location: nil, 266 | timezone: nil, 267 | isAllDay: true), 268 | event( 269 | name: "Understand", 270 | startDateTime: dateWithTime("12:00", onDay: date), 271 | endDateTime: dateWithTime("12:00", onDay: date), 272 | location: nil, 273 | timezone: nil, 274 | isAllDay: true), 275 | event( 276 | name: "Ponder", 277 | startDateTime: dateWithTime("06:00", onDay: date), 278 | endDateTime: dateWithTime("22:00", onDay: date), 279 | location: nil, 280 | timezone: nil, 281 | isAllDay: false) 282 | ] 283 | } 284 | 285 | } 286 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDatePickerView.m: -------------------------------------------------------------------------------- 1 | #import "TCNDatePickerDataSource.h" 2 | #import "TCNDatePickerView.h" 3 | #import "TCNDateUtil.h" 4 | #import "TCNDateFormatter.h" 5 | #import "TCNDatePickerDayView.h" 6 | #import "TCNDatePickerSelectionIndicatorView.h" 7 | #import "TCNMacros.h" 8 | #import "TCNNumberHelper.h" 9 | #import "TCNDatePickerLayout.h" 10 | #import "TCNViewUtils.h" 11 | 12 | @interface TCNDatePickerView () 13 | 14 | @property (nonatomic, strong, nonnull, readonly) TCNDatePickerLayout *collectionViewLayout; 15 | @property (nonatomic, strong, nonnull, readonly) TCNDatePickerConfig *config; 16 | @property (nonatomic, strong, nonnull, readonly) TCNDatePickerDataSource *datePickerDataSource; 17 | @property (nonatomic, strong, nonnull, readonly) UICollectionView *collectionView; 18 | @property (nonatomic, strong, nonnull, readonly) UILabel *monthLabel; 19 | 20 | /** 21 | The is used to re-layout the collection view if we detect a width change, and is updated every time @c layoutSubviews is called. 22 | This helps to handle screen rotation or app resizing. 23 | */ 24 | @property (nonatomic, assign, readwrite) CGFloat collectionViewItemWidth; 25 | 26 | /** 27 | A state variable used to keep track of the last @c contentOffset of @c collectionView. 28 | Used to give a snapping effect to our @c collectionView 29 | */ 30 | @property (nonatomic, assign, readwrite) CGPoint lastContentOffset; 31 | 32 | @end 33 | 34 | @implementation TCNDatePickerView 35 | 36 | static const CGFloat DayItemHeight = 44.0f; 37 | static const CGFloat DayViewInterItemSpacing = 8.0f; 38 | static const CGFloat DayViewLineSpacing = 8.0f; 39 | static const CGFloat HorizontalInsetDimension = 8.0f; 40 | static const CGFloat VerticalInterItemSpacing = 12.0f; 41 | static const NSInteger DaysInAWeek = 7; 42 | 43 | /** 44 | This adds some padding above and below the text in the title label. 45 | */ 46 | static const CGFloat AdditionalTitleLabelHeight = 1.0f; 47 | 48 | #pragma mark - Initialization 49 | 50 | - (nonnull instancetype)initWithFrame:(CGRect)frame { 51 | return [self initWithFrame:frame config:[[TCNDatePickerConfig alloc] init]]; 52 | } 53 | 54 | - (nonnull instancetype)initWithFrame:(CGRect)frame config:(nonnull TCNDatePickerConfig *)config { 55 | self = [super initWithFrame:frame]; 56 | if (!self) { 57 | return nil; 58 | } 59 | 60 | _collectionViewItemWidth = 0; 61 | _config = config; 62 | _monthLabel = [TCNDatePickerView labelWithConfig:config andSuperview:self]; 63 | _collectionViewLayout = [TCNDatePickerView collectionViewLayoutWithConfig:config]; 64 | _datePickerDataSource = [TCNDatePickerView collectionViewDataSourceWithConfig:config]; 65 | _collectionView = [TCNDatePickerView collectionViewWithConfig:config layout:_collectionViewLayout dataSource:_datePickerDataSource superview:self]; 66 | 67 | [TCNDatePickerView configureView:self withConfig:config]; 68 | 69 | return self; 70 | } 71 | 72 | #pragma mark - Class Methods 73 | 74 | + (CGFloat)heightRequiredForConfig:(nonnull TCNDatePickerConfig *)config { 75 | return (3 * VerticalInterItemSpacing) 76 | + DayItemHeight 77 | + config.monthLabelFont.lineHeight + AdditionalTitleLabelHeight; 78 | } 79 | 80 | /** 81 | Returns the width per day item given a bounding width. 82 | */ 83 | + (CGFloat)collectionViewItemWidthForBoundingWidth:(CGFloat)width { 84 | const CGFloat unroundedWidth = (width - (2.0f * HorizontalInsetDimension) - ((DaysInAWeek - 1) * DayViewInterItemSpacing)) / 7; 85 | return [TCNNumberHelper floor:unroundedWidth]; 86 | } 87 | 88 | /** 89 | Returns the additional right inset for a given bounding width. 90 | Additional spacing is created because of item widths being an integer value. 91 | */ 92 | + (CGFloat)additionalCollectionViewRightInsetSpacingForBoundingWidth:(CGFloat)boundingWidth andItemWidth:(CGFloat)itemWidth { 93 | return boundingWidth 94 | - (DaysInAWeek * itemWidth) 95 | - (2 * HorizontalInsetDimension) 96 | - ((DaysInAWeek - 1) * DayViewInterItemSpacing); 97 | } 98 | 99 | + (void)configureView:(nonnull UIView *)view withConfig:(nonnull TCNDatePickerConfig *)config { 100 | view.backgroundColor = config.backgroundColor; 101 | } 102 | 103 | + (nonnull UILabel *)labelWithConfig:(nonnull TCNDatePickerConfig *)config andSuperview:(UIView *)view { 104 | UILabel *const label = [[UILabel alloc] init]; 105 | label.font = config.monthLabelFont; 106 | label.textColor = config.textColor; 107 | label.textAlignment = NSTextAlignmentCenter; 108 | [view addSubview:label]; 109 | return label; 110 | } 111 | 112 | + (nonnull UICollectionView *)collectionViewWithConfig:(nonnull TCNDatePickerConfig *)config 113 | layout:(nonnull UICollectionViewLayout *)layout 114 | dataSource:(nonnull TCNDatePickerDataSource *)dataSource 115 | superview:(nonnull UIView *)superview { 116 | UICollectionView *const collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout]; 117 | collectionView.dataSource = dataSource; 118 | collectionView.pagingEnabled = YES; 119 | collectionView.showsHorizontalScrollIndicator = NO; 120 | collectionView.showsVerticalScrollIndicator = NO; 121 | collectionView.allowsMultipleSelection = NO; 122 | collectionView.backgroundColor = UIColor.clearColor; 123 | [collectionView registerClass:[TCNDatePickerDayView class] forCellWithReuseIdentifier:TCNDatePickerDayView.reuseIdentifier]; 124 | [superview addSubview:collectionView]; 125 | 126 | if (config.datePickerBackgroundProvider) { 127 | collectionView.backgroundView = config.datePickerBackgroundProvider(); 128 | } 129 | 130 | if (config.customDatePickerViewConfig) { 131 | config.customDatePickerViewConfig(collectionView); 132 | } 133 | 134 | return collectionView; 135 | } 136 | 137 | + (nonnull TCNDatePickerLayout *)collectionViewLayoutWithConfig:(nonnull TCNDatePickerConfig *)config { 138 | TCNDatePickerLayout *const collectionViewLayout = [[TCNDatePickerLayout alloc] initWithConfig:config]; 139 | collectionViewLayout.scrollDirection = UICollectionViewScrollDirectionHorizontal; 140 | collectionViewLayout.minimumLineSpacing = DayViewLineSpacing; 141 | collectionViewLayout.minimumInteritemSpacing = DayViewInterItemSpacing; 142 | [collectionViewLayout registerClass:[TCNDatePickerSelectionIndicatorView class] 143 | forDecorationViewOfKind:TCNDatePickerSelectionIndicatorView.reuseIdentifier]; 144 | return collectionViewLayout; 145 | } 146 | 147 | + (nonnull TCNDatePickerDataSource *)collectionViewDataSourceWithConfig:(nonnull TCNDatePickerConfig *)config { 148 | TCNDatePickerDataSource *const datePickerDataSource = [[TCNDatePickerDataSource alloc] initWithConfig:config]; 149 | [datePickerDataSource setupWeekDatesWithCurrentlyVisibleDate:datePickerDataSource.selectedDate]; 150 | return datePickerDataSource; 151 | } 152 | 153 | #pragma mark - Methods 154 | 155 | - (void)willMoveToSuperview:(UIView *)newSuperview { 156 | [super willMoveToSuperview:newSuperview]; 157 | self.collectionViewLayout.delegate = self; 158 | self.collectionView.delegate = self; 159 | 160 | [self updateMonthLabelWithDate:[TCNDateUtil middleOfWeekForDate:[[NSDate alloc] init]]]; 161 | } 162 | 163 | - (void)selectDate:(nonnull NSDate *)date animated:(__unused BOOL)animated { 164 | NSIndexPath *const indexPathOfNewDate = [self.datePickerDataSource indexPathForDate:date]; 165 | 166 | if (indexPathOfNewDate && indexPathOfNewDate.section == [TCNDatePickerDataSource datePickerSectionActiveWeek]) { 167 | // if the new date is in the current active week, then we don't need to do unnecessary reload 168 | // we can trigger cell selection 169 | [self triggerSelectInCollectionViewForIndexPath:indexPathOfNewDate]; 170 | } else { 171 | [self updateDataSourceWithSelectedDate:date]; 172 | [self.collectionView reloadData]; 173 | 174 | [self scrollToActiveWeek]; 175 | } 176 | } 177 | 178 | - (void)updateDataSourceWithSelectedDate:(nonnull NSDate *)date { 179 | if (self.selectedDate && [TCNDateUtil isDate:date inSameDayAsDate:self.selectedDate]) { 180 | return; 181 | } 182 | self.datePickerDataSource.selectedDate = date; 183 | [self.datePickerDataSource setupWeekDatesWithCurrentlyVisibleDate:date]; 184 | } 185 | 186 | - (void)updateMonthLabelWithDate:(nonnull NSDate *)date { 187 | self.monthLabel.text = [TCNDateFormatter.monthAndYearFormatter stringFromDate:date]; 188 | } 189 | 190 | - (nonnull NSDate *)selectedDate { 191 | return self.datePickerDataSource.selectedDate; 192 | } 193 | 194 | - (void)layoutSubviews { 195 | [super layoutSubviews]; 196 | 197 | // The monthLabel will adhere to a bounding width. The collectionView also adheres to this width 198 | // but this logic will be handled by its contentInset. 199 | const CGFloat layoutBoundingWidth = self.frame.size.width - (2.0f * HorizontalInsetDimension); 200 | self.monthLabel.frame = CGRectMake( 201 | HorizontalInsetDimension, 202 | VerticalInterItemSpacing, 203 | layoutBoundingWidth, 204 | self.config.monthLabelFont.lineHeight + AdditionalTitleLabelHeight); 205 | 206 | const CGFloat itemWidth = [TCNDatePickerView collectionViewItemWidthForBoundingWidth:self.frame.size.width]; 207 | const CGFloat additionalRightSpacing = [TCNDatePickerView additionalCollectionViewRightInsetSpacingForBoundingWidth:self.frame.size.width 208 | andItemWidth:itemWidth]; 209 | self.collectionView.frame = CGRectMake( 210 | 0, 211 | self.monthLabel.frame.origin.y + self.monthLabel.frame.size.height + VerticalInterItemSpacing, 212 | self.frame.size.width, 213 | DayItemHeight); 214 | self.collectionView.contentInset = UIEdgeInsetsMake( 215 | 0, 216 | HorizontalInsetDimension, 217 | 0, 218 | HorizontalInsetDimension + additionalRightSpacing); 219 | self.collectionViewItemWidth = itemWidth; 220 | self.collectionViewLayout.sectionInset = self.collectionView.contentInset; 221 | 222 | [self.collectionView.collectionViewLayout invalidateLayout]; 223 | [self scrollToActiveWeek]; 224 | } 225 | 226 | - (void)scrollToActiveWeek { 227 | const UICollectionViewScrollPosition scrollPosition = [TCNViewUtils isLayoutDirectionRTL] 228 | ? UICollectionViewScrollPositionRight 229 | : UICollectionViewScrollPositionLeft; 230 | [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:[TCNDatePickerDataSource datePickerSectionActiveWeek]] 231 | atScrollPosition:scrollPosition 232 | animated:NO]; 233 | self.lastContentOffset = self.collectionView.contentOffset; 234 | } 235 | 236 | - (void)triggerSelectInCollectionViewForIndexPath:(nonnull NSIndexPath *)indexPath { 237 | [self.collectionView selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; 238 | [self collectionView:self.collectionView didSelectItemAtIndexPath:indexPath]; 239 | } 240 | 241 | #pragma mark - UICollectionViewDelegate 242 | 243 | - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { 244 | // Given the indexPath, find the date object 245 | NSDate *const newSelectedDate = [self.datePickerDataSource dateForItemAtIndexPath:indexPath]; 246 | if (!newSelectedDate) { 247 | NSAssert(NO, @"newSelectedDate is nil for indexPath in didSelectItemAtIndexPath"); 248 | return; 249 | } 250 | 251 | // find the old indexPath so we can reload its cell along with the new indexPath 252 | NSIndexPath *const oldSelectedIndexPath = [self.datePickerDataSource indexPathForDate:self.selectedDate]; 253 | NSMutableArray *const indexPathsToReload = [[NSMutableArray alloc] initWithObjects:indexPath, nil]; 254 | if (oldSelectedIndexPath) { 255 | [indexPathsToReload addObject:oldSelectedIndexPath]; 256 | } 257 | 258 | // select the new date 259 | [self updateDataSourceWithSelectedDate:newSelectedDate]; 260 | [self.collectionViewLayout invalidateLayout]; 261 | 262 | // If the new selection is a different indexPath, 263 | // we need to reload the old cells and new cell in performWithoutAnimation block to avoid screen flickering. 264 | if (indexPath != oldSelectedIndexPath) { 265 | [UIView performWithoutAnimation:^{ 266 | [collectionView reloadItemsAtIndexPaths:indexPathsToReload]; 267 | }]; 268 | } 269 | 270 | [self.datePickerDelegate datePickerView:self didSelectDate:newSelectedDate]; 271 | } 272 | 273 | - (void)scrollViewDidEndDecelerating:(__unused UIScrollView *)scrollView { 274 | NSDate *newDate; 275 | 276 | // If there is enough change in the last content offset, then we'll swipe 277 | if (self.collectionView.contentOffset.x - self.lastContentOffset.x > ceil(self.collectionView.frame.size.width / 3.0f)) { 278 | //the user scrolled to the left moving to the next week 279 | 280 | newDate = [self.datePickerDataSource.nextWeekDates objectAtIndex:0]; 281 | } else if (self.lastContentOffset.x - self.collectionView.contentOffset.x > ceil(self.collectionView.frame.size.width / 3.0f)) { 282 | //the user scrolled to the right moving to the previous week 283 | 284 | newDate = [self.datePickerDataSource.previousWeekDates objectAtIndex:0]; 285 | } else { 286 | return; 287 | } 288 | 289 | [self updateMonthLabelWithDate:[TCNDateUtil middleOfWeekForDate:newDate]]; 290 | 291 | [self.datePickerDataSource setupWeekDatesWithCurrentlyVisibleDate:newDate]; 292 | [self.collectionView reloadData]; 293 | 294 | [self scrollToActiveWeek]; 295 | } 296 | 297 | # pragma mark - TCNDatePickerLayoutDelegate 298 | 299 | - (nullable NSIndexPath *)selectedDateIndexPath { 300 | return [self.datePickerDataSource indexPathForDate:self.selectedDate]; 301 | } 302 | 303 | - (CGSize)collectionView:(__unused UICollectionView *)collectionView 304 | layout:(__unused UICollectionViewLayout*)collectionViewLayout 305 | sizeForItemAtIndexPath:(__unused NSIndexPath *)indexPath { 306 | return CGSizeMake(self.collectionViewItemWidth, DayItemHeight); 307 | } 308 | 309 | @end 310 | -------------------------------------------------------------------------------- /Tachyon/Public API/TCNDayView.m: -------------------------------------------------------------------------------- 1 | #import "TCNAllDayViewLayout.h" 2 | #import "TCNDateUtil.h" 3 | #import "TCNDayView.h" 4 | #import "TCNDayViewLayout.h" 5 | #import "TCNDayViewGridlineView.h" 6 | #import "TCNDayViewTimeView.h" 7 | #import "TCNMacros.h" 8 | #import "TCNEventCell.h" 9 | 10 | @interface TCNDayView () 11 | 12 | @property (nonatomic, strong, nonnull, readonly) TCNDayViewLayout *collectionViewLayout; 13 | @property (nonatomic, strong, nonnull, readonly) TCNDayViewLayout *allDayCollectionViewLayout; 14 | @property (nonatomic, strong, nonnull, readonly) UICollectionView *collectionView; 15 | @property (nonatomic, strong, nonnull, readonly) UICollectionView *allDayCollectionView; 16 | @property (nonatomic, strong, nonnull, readonly) TCNDayViewConfig *config; 17 | @property (nonatomic, strong, nonnull, readonly) UITapGestureRecognizer *tapGestureRecognizer; 18 | 19 | @end 20 | 21 | @implementation TCNDayView 22 | 23 | #pragma mark - Static 24 | 25 | static const NSInteger DefaultHour = 8; 26 | 27 | #pragma mark - Initialization 28 | 29 | - (nonnull instancetype)initWithFrame:(CGRect)frame { 30 | return [self initWithFrame:frame config:[[TCNDayViewConfig alloc] init]]; 31 | } 32 | 33 | - (nonnull instancetype)initWithFrame:(CGRect)frame config:(nonnull TCNDayViewConfig *)config { 34 | self = [super initWithFrame:frame]; 35 | if (!self) { 36 | return nil; 37 | } 38 | 39 | _defaultHour = DefaultHour; 40 | _config = config; 41 | _collectionViewLayout = [TCNDayView collectionViewLayoutWithConfig:config isAllDay:NO]; 42 | _allDayCollectionViewLayout = [TCNDayView collectionViewLayoutWithConfig:config isAllDay:YES]; 43 | _collectionView = [TCNDayView collectionViewWithConfig:config collectionViewLayout:_collectionViewLayout superview:self isAllDay:NO]; 44 | _allDayCollectionView = [TCNDayView collectionViewWithConfig:config collectionViewLayout:_allDayCollectionViewLayout superview:self isAllDay:YES]; 45 | _tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(dayViewTapped:)]; 46 | [_collectionView addGestureRecognizer:_tapGestureRecognizer]; 47 | 48 | return self; 49 | } 50 | 51 | #pragma mark - Class helpers 52 | 53 | + (nonnull TCNDayViewLayout *)collectionViewLayoutWithConfig:(nonnull TCNDayViewConfig *)config isAllDay:(BOOL)isAllDay { 54 | TCNDayViewLayout *const collectionViewLayout = isAllDay 55 | ? [[TCNAllDayViewLayout alloc] initWithConfig:config] 56 | : [[TCNDayViewLayout alloc] initWithConfig:config]; 57 | [collectionViewLayout registerClass:TCNDayViewGridlineView.class forDecorationViewOfKind:TCNDayViewGridlineView.darkKind]; 58 | [collectionViewLayout registerClass:TCNDayViewGridlineView.class forDecorationViewOfKind:TCNDayViewGridlineView.lightKind]; 59 | return collectionViewLayout; 60 | } 61 | 62 | + (nonnull UICollectionView *)collectionViewWithConfig:(nonnull TCNDayViewConfig *)config 63 | collectionViewLayout:(nonnull TCNDayViewLayout *)layout 64 | superview:(nonnull UIView *)superview 65 | isAllDay:(BOOL)isAllDay { 66 | UICollectionView *const collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout]; 67 | collectionView.backgroundColor = config.backgroundColor; 68 | 69 | collectionView.directionalLockEnabled = YES; 70 | collectionView.showsHorizontalScrollIndicator = NO; 71 | collectionView.showsVerticalScrollIndicator = NO; 72 | [collectionView registerClass:TCNEventCell.class forCellWithReuseIdentifier:TCNEventCell.reuseIdentifier]; 73 | [collectionView registerClass:TCNDayViewTimeView.class 74 | forSupplementaryViewOfKind:TCNDayViewTimeView.reuseIdentifier 75 | withReuseIdentifier:TCNDayViewTimeView.reuseIdentifier]; 76 | 77 | [superview addSubview:collectionView]; 78 | 79 | if (isAllDay) { 80 | if (config.allDayViewBackgroundProvider) { 81 | collectionView.backgroundView = config.allDayViewBackgroundProvider(); 82 | } 83 | if (config.customAllDayViewConfig) { 84 | config.customAllDayViewConfig(collectionView); 85 | } 86 | } else { 87 | if (config.dayViewBackgroundProvider) { 88 | collectionView.backgroundView = config.dayViewBackgroundProvider(); 89 | } 90 | if (config.customDayViewConfig) { 91 | config.customDayViewConfig(collectionView); 92 | } 93 | } 94 | 95 | return collectionView; 96 | } 97 | 98 | /** 99 | Returns a calendar event with the selected date, with a default length of one hour. 100 | The start time is determined by rounding the given date to the nearest interval, based off @c TCNDayViewConfig.defaultEventLength. 101 | 102 | @param date The date tapped on the day view. 103 | @param name The name of the event to be created. 104 | @param length The length of the event to be created, in minutes. 105 | */ 106 | + (nonnull TCNEvent *)calendarEventWithSelectedDate:(nonnull NSDate *)date eventName:(nonnull NSString *)name eventLength:(TCNEventLength)length { 107 | NSDateComponents *const components = [[NSCalendar currentCalendar] componentsInTimeZone:[NSTimeZone localTimeZone] fromDate:date]; 108 | const NSInteger secondsInSelectedTime = (components.hour * 3600) + (components.minute * 60); 109 | 110 | const NSInteger secondsPerChunk = ((NSInteger)length) * 60; 111 | const double chunks = secondsInSelectedTime / secondsPerChunk; 112 | const NSInteger roundedSeconds = ((NSInteger)round(chunks)) * secondsPerChunk; 113 | const NSInteger newHour = roundedSeconds / 3600; 114 | const NSInteger newMinute = (roundedSeconds - (newHour * 3600)) / 60; 115 | NSDate *const newDate = [TCNDateUtil dateWithDate:date 116 | atHour:newHour 117 | andMinute:newMinute]; 118 | 119 | NSDate *const endDate = [[NSCalendar currentCalendar] dateByAddingUnit:NSCalendarUnitMinute 120 | value:length 121 | toDate:newDate 122 | options:0]; 123 | TCNEvent *const event = [[TCNEvent alloc] initWithName:name 124 | startDateTime:newDate 125 | endDateTime:endDate 126 | location:nil 127 | timezone:[NSTimeZone localTimeZone] 128 | isAllDay:NO]; 129 | event.isSelected = YES; 130 | return event; 131 | } 132 | 133 | #pragma mark - Methods 134 | 135 | - (void)reloadAndResetScrolling:(BOOL)resetScrolling { 136 | [self.collectionView reloadData]; 137 | [self.allDayCollectionView reloadData]; 138 | [self setNeedsLayout]; 139 | 140 | if (!resetScrolling) { 141 | return; 142 | } 143 | 144 | // We are able to scroll without forcing a layout because the UICollectionView will calculate indexes and offsets before 145 | // returning from reloadData. 146 | NSIndexPath *const indexPathForDefaultHour = [NSIndexPath indexPathForRow:self.defaultHour inSection:0]; 147 | const CGFloat yOffsetForDefaultHour = [TCNDayViewLayout offsetForIndexPath:indexPathForDefaultHour 148 | minY:TCNDayViewLayout.topInsetMargin]; 149 | // The offset to scroll to is calculated as the hour offset + the collection view's height - the default top inset, so that the 150 | // hour is displayed at the top of the screen. 151 | const CGFloat yOffsetToScrollTo = yOffsetForDefaultHour + self.collectionView.frame.size.height - TCNDayViewLayout.topInsetMargin; 152 | [self.collectionView scrollRectToVisible:CGRectMake(0, 0, 1, 1) animated:NO]; 153 | [self.collectionView scrollRectToVisible:CGRectMake(0, yOffsetToScrollTo, 1, 1) animated:NO]; 154 | } 155 | 156 | - (nonnull NSArray *)dayEvents { 157 | return self.dataSource.dayEvents ?: @[]; 158 | } 159 | 160 | - (nonnull NSArray *)allDayEvents { 161 | return self.dataSource.allDayEvents ?: @[]; 162 | } 163 | 164 | - (void)dayViewTapped:(nonnull UITapGestureRecognizer *)recognizer { 165 | NSDate *const currentDate = self.dataSource.currentDate; 166 | if (!currentDate) { 167 | return; 168 | } 169 | const CGPoint location = [recognizer locationInView:self.collectionView]; 170 | NSDateComponents *const tappedComponents = [[NSCalendar currentCalendar] componentsInTimeZone:[NSTimeZone localTimeZone] 171 | fromDate:[TCNDayViewLayout timeForYOffset:location.y]]; 172 | NSDate *const selectedDate = [TCNDateUtil dateWithDate:TCN_FORCE_UNWRAP(currentDate) 173 | atHour:tappedComponents.hour 174 | andMinute:tappedComponents.minute]; 175 | 176 | [self.delegate dayView:self didSelectAvailabilityWithEvent:[TCNDayView calendarEventWithSelectedDate:selectedDate 177 | eventName:self.config.createdEventText 178 | eventLength:self.config.defaultEventLength]]; 179 | } 180 | 181 | #pragma mark - View Lifecycle 182 | 183 | - (void)layoutSubviews { 184 | [super layoutSubviews]; 185 | 186 | const NSInteger numberOfAllDayItems = [self.allDayCollectionView numberOfItemsInSection:0]; 187 | if (numberOfAllDayItems) { 188 | [self.allDayCollectionView scrollRectToVisible:CGRectZero animated:false]; 189 | const CGFloat allDayViewHeight = [TCNAllDayViewLayout allDayViewHeightForEventCount:numberOfAllDayItems]; 190 | self.collectionView.frame = CGRectMake(0, allDayViewHeight, self.bounds.size.width, self.bounds.size.height - allDayViewHeight); 191 | self.allDayCollectionView.frame = CGRectMake(0, 0, self.bounds.size.width, allDayViewHeight); 192 | } else { 193 | self.collectionView.frame = self.bounds; 194 | self.allDayCollectionView.frame = CGRectZero; 195 | } 196 | } 197 | 198 | - (void)willMoveToSuperview:(UIView *)newSuperview { 199 | [super willMoveToSuperview:newSuperview]; 200 | 201 | self.collectionViewLayout.delegate = self; 202 | self.collectionView.dataSource = self; 203 | self.collectionView.delegate = self; 204 | self.allDayCollectionViewLayout.delegate = self; 205 | self.allDayCollectionView.dataSource = self; 206 | self.allDayCollectionView.delegate = self; 207 | } 208 | 209 | #pragma mark - UICollectionViewDataSource 210 | 211 | - (NSInteger)numberOfSectionsInCollectionView:(__unused UICollectionView *)collectionView { 212 | return 1; 213 | } 214 | 215 | - (NSInteger)collectionView:(__unused UICollectionView *)collectionView numberOfItemsInSection:(__unused NSInteger)section { 216 | if (collectionView == self.collectionView) { 217 | return (NSInteger)self.dayEvents.count; 218 | } else { 219 | return (NSInteger)self.allDayEvents.count; 220 | } 221 | } 222 | 223 | - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { 224 | NSArray *const events = collectionView == self.collectionView ? self.dayEvents : self.allDayEvents; 225 | UICollectionViewCell *const collectionViewCell = [collectionView dequeueReusableCellWithReuseIdentifier:TCNEventCell.reuseIdentifier 226 | forIndexPath:indexPath]; 227 | TCNEventCell *const eventCell = TCN_CAST_OR_NIL(collectionViewCell, TCNEventCell); 228 | if (!eventCell) { 229 | return collectionViewCell; 230 | } 231 | TCNEvent *const event = events[(NSUInteger)indexPath.row]; 232 | __weak typeof(self) weakSelf = self; 233 | eventCell.cancelHandler = ^{ 234 | typeof(self) strongSelf = weakSelf; 235 | id strongDelegate = strongSelf.delegate; 236 | if (!strongSelf || !strongDelegate) { 237 | return; 238 | } 239 | if ([strongDelegate respondsToSelector:@selector(dayView:didCancelEvent:)]) { 240 | [strongDelegate dayView:strongSelf didCancelEvent:event]; 241 | } 242 | }; 243 | [eventCell updateWithEvent:event]; 244 | [eventCell applyStylingFromConfig:self.config selected:event.isSelected]; 245 | return eventCell; 246 | } 247 | 248 | - (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView 249 | viewForSupplementaryElementOfKind:(NSString *)kind 250 | atIndexPath:(NSIndexPath *)indexPath { 251 | UICollectionReusableView *const reusableView = [collectionView dequeueReusableSupplementaryViewOfKind:kind 252 | withReuseIdentifier:TCNDayViewTimeView.reuseIdentifier 253 | forIndexPath:indexPath]; 254 | if (![kind isEqualToString:TCNDayViewTimeView.reuseIdentifier] || (collectionView == self.allDayCollectionView && indexPath.row > 0)) { 255 | return reusableView; 256 | } 257 | 258 | TCNDayViewTimeView *const timeView = TCN_CAST_OR_NIL(reusableView, TCNDayViewTimeView); 259 | if (!timeView) { 260 | NSAssert(NO, @"Unable to cast UICollectionReusableView to TCNDayViewTimeView"); 261 | return nil; 262 | } 263 | [timeView applyStylingFromConfig:self.config selected:NO]; 264 | NSDate *const time = [self timeForTimeViewAtIndexPath:indexPath]; 265 | if (collectionView == self.collectionView) { 266 | timeView.time = time; 267 | } else { 268 | [timeView updateAllDayEventText]; 269 | } 270 | 271 | return timeView; 272 | } 273 | 274 | - (nonnull NSDate *)timeForTimeViewAtIndexPath:(nonnull NSIndexPath *)indexPath { 275 | NSDateComponents *const dateComponents = [[NSDateComponents alloc] init]; 276 | dateComponents.hour = indexPath.item; 277 | 278 | NSDate *const date = [[NSCalendar currentCalendar] dateFromComponents:dateComponents]; 279 | if (!date) { 280 | TCN_ASSERT_FAILURE(@"No date created in dateForTimeViewAtIndexPath"); 281 | // we'll just return current date if we fail to calculate a date 282 | return [NSDate date]; 283 | } 284 | 285 | return date; 286 | } 287 | 288 | #pragma mark - TCNDayViewLayoutDelegate 289 | 290 | /** 291 | This method will calculate the appropriate start time to display. 292 | 293 | - If the event does not occur at all on this day, return the beginning of this day. 294 | - Else, if the event is all day, this will just return the beginning of this day view's day. 295 | - Else, we return the later time of either the event's start time or the beginning of the day view's day. 296 | This accounts for the possibility that this is a multi-day event. 297 | */ 298 | - (nonnull NSDate *)collectionView:(nullable UICollectionView *)collectionView 299 | layout:(nonnull __unused TCNDayViewLayout *)collectionViewLayout 300 | startTimeForItemAtIndexPath:(NSIndexPath *)indexPath { 301 | NSDate *const currentDate = self.dataSource.currentDate; 302 | if (!currentDate || !collectionView) { 303 | return [NSDate date]; 304 | } 305 | 306 | TCNEvent *const event = [self eventForIndexPath:indexPath collectionView:TCN_FORCE_UNWRAP(collectionView)]; 307 | if (!event) { 308 | return [NSDate new]; 309 | } 310 | NSDate *const startOfThisDay = [[NSCalendar currentCalendar] startOfDayForDate:TCN_FORCE_UNWRAP(currentDate)]; 311 | if (!startOfThisDay) { 312 | return [NSDate date]; 313 | } 314 | 315 | if (![event occursOnDay:currentDate]) { 316 | return startOfThisDay; 317 | } else if (event.isAllDay) { 318 | return startOfThisDay; 319 | } else { 320 | return [TCNDateUtil latestDate:event.startDateTime otherDate:startOfThisDay]; 321 | } 322 | } 323 | 324 | /** 325 | This method will calculate the appropriate end time to display. 326 | 327 | - If the event does not occur on this day, return the beginning of this day. 328 | - Else, if the event is all day, this will just return the end of this day view's day. 329 | - Else, we return the earlier time of either the event's end time or the end of the day view's day. 330 | This accounts for the possibility that this is a multi-day event. 331 | */ 332 | - (nonnull NSDate *)collectionView:(nullable UICollectionView *)collectionView 333 | layout:(nonnull __unused TCNDayViewLayout *)collectionViewLayout 334 | endTimeForItemAtIndexPath:(nonnull NSIndexPath *)indexPath { 335 | NSDate *const currentDateOrNil = self.dataSource.currentDate; 336 | if (!currentDateOrNil || !collectionView) { 337 | return [NSDate date]; 338 | } 339 | NSDate *const currentDate = TCN_FORCE_UNWRAP(currentDateOrNil); 340 | 341 | TCNEvent *const event = [self eventForIndexPath:indexPath collectionView:TCN_FORCE_UNWRAP(collectionView)]; 342 | if (!event) { 343 | return [NSDate new]; 344 | } 345 | NSDate *const startOfThisDay = [[NSCalendar currentCalendar] startOfDayForDate:currentDate]; 346 | NSDate *const endOfThisDay = [TCNDateUtil endOfDayForDate:currentDate]; 347 | if (!startOfThisDay) { 348 | return [NSDate date]; 349 | } 350 | 351 | if (![event occursOnDay:currentDate]) { 352 | return startOfThisDay; 353 | } else if (event.isAllDay) { 354 | return startOfThisDay; 355 | } else { 356 | return [TCNDateUtil earliestDate:event.endDateTime otherDate:endOfThisDay]; 357 | } 358 | } 359 | 360 | /** 361 | Returns true if we don't want to adjust frames for the given item, and instead allow it to display over unselected items. 362 | */ 363 | - (BOOL)collectionView:(nullable __unused UICollectionView *)collectionView 364 | layout:(nonnull __unused TCNDayViewLayout *)collectionViewLayout 365 | shouldAdjustLayoutForItemAtIndexPath:(nonnull NSIndexPath *)indexPath { 366 | if (!collectionView) { 367 | return NO; 368 | } 369 | return ![self eventForIndexPath:indexPath collectionView:TCN_FORCE_UNWRAP(collectionView)].isSelected; 370 | } 371 | 372 | - (nullable TCNEvent *)eventForIndexPath:(nonnull NSIndexPath *)indexPath collectionView:(nonnull UICollectionView *)collectionView { 373 | NSArray *const events = [(collectionView == self.collectionView ? self.dayEvents : self.allDayEvents) copy]; 374 | NSUInteger index = (NSUInteger)indexPath.row; 375 | if (events.count <= index) { 376 | TCN_ASSERT_FAILURE(@"Index out of bounds for calendar data source"); 377 | return nil; 378 | } 379 | return events[index]; 380 | } 381 | 382 | @end 383 | --------------------------------------------------------------------------------