├── .gitignore ├── README.md ├── SwiftUIStopwatch.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── SwiftUIStopwatch.xcscheme └── SwiftUIStopwatch ├── AppDelegate.swift ├── Assets.xcassets ├── AppIcon.appiconset │ └── Contents.json └── Contents.json ├── Base.lproj └── LaunchScreen.storyboard ├── Buttons └── Buttons.swift ├── Entities └── LapTime.swift ├── Extensions └── TimeInterval+Ext.swift ├── Info.plist ├── Model └── Stopwatch.swift ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── SceneDelegate.swift ├── Utilities └── TimeController.swift └── Views ├── LapTimeRow.swift ├── StopwatchControlsView.swift ├── StopwatchView.swift └── TimeView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | ######################### 2 | # **.gitignore** file for Xcode4 / OS X Source projects 3 | # 4 | # NB: if you are storing "built" products, this WILL NOT WORK, 5 | # and you should use a different **.gitignore** (or none at all) 6 | # This file is for SOURCE projects, where there are many extra 7 | # files that we want to exclude 8 | # 9 | # For updates, see: http://stackoverflow.com/questions/49478/git-ignore-file-for-xcode-projects 10 | ######################### 11 | 12 | ##### 13 | # OS X temporary files that should never be committed 14 | 15 | .DS_Store 16 | *.swp 17 | profile 18 | 19 | 20 | #### 21 | # Xcode temporary files that should never be committed 22 | # 23 | # NB: NIB/XIB files still exist even on Storyboard projects, so we want this... 24 | 25 | *~.nib 26 | 27 | 28 | #### 29 | # Xcode build files - 30 | # 31 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "DerivedData" 32 | 33 | DerivedData/ 34 | 35 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "build" 36 | 37 | build/ 38 | 39 | 40 | ##### 41 | # Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups) 42 | # 43 | # This is complicated: 44 | # 45 | # SOMETIMES you need to put this file in version control. 46 | # Apple designed it poorly - if you use "custom executables", they are 47 | # saved in this file. 48 | # 99% of projects do NOT use those, so they do NOT want to version control this file. 49 | # ..but if you're in the 1%, comment out the line "*.pbxuser" 50 | 51 | *.pbxuser 52 | *.mode1v3 53 | *.mode2v3 54 | *.perspectivev3 55 | # NB: also, whitelist the default ones, some projects need to use these 56 | !default.pbxuser 57 | !default.mode1v3 58 | !default.mode2v3 59 | !default.perspectivev3 60 | 61 | 62 | #### 63 | # Xcode 4 - semi-personal settings, often included in workspaces 64 | # 65 | # You can safely ignore the xcuserdata files - but do NOT ignore the files next to them 66 | # 67 | 68 | xcuserdata 69 | 70 | #### 71 | # XCode 4 workspaces - more detailed 72 | # 73 | # Workspaces are important! They are a core feature of Xcode - don't exclude them :) 74 | # 75 | # Workspace layout is quite spammy. For reference: 76 | # 77 | # (root)/ 78 | # (project-name).xcodeproj/ 79 | # project.pbxproj 80 | # project.xcworkspace/ 81 | # contents.xcworkspacedata 82 | # xcuserdata/ 83 | # (your name)/xcuserdatad/ 84 | # xcuserdata/ 85 | # (your name)/xcuserdatad/ 86 | # 87 | # 88 | # 89 | # Xcode 4 workspaces - SHARED 90 | # 91 | # This is UNDOCUMENTED (google: "developer.apple.com xcshareddata" - 0 results 92 | # But if you're going to kill personal workspaces, at least keep the shared ones... 93 | # 94 | # 95 | !xcshareddata 96 | 97 | #### 98 | # XCode 4 build-schemes 99 | # 100 | # PRIVATE ones are stored inside xcuserdata 101 | !xcschemes 102 | 103 | #### 104 | # Xcode 4 - Deprecated classes 105 | # 106 | # Allegedly, if you manually "deprecate" your classes, they get moved here. 107 | # 108 | # We're using source-control, so this is a "feature" that we do not want! 109 | 110 | *.moved-aside 111 | 112 | # CocoaPods 113 | /Pods -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUIStopwatch 2 | A SwiftUI implementation of the stopwatch feature in Apple's Clock app on iOS. 3 | 4 | A stopwatch is a simple, single screen app. Yet, is a classic example of how an app's UI can easily fall out of sync with it's model. 5 | 6 | SwiftUI promises 'correct' user interfaces through it's declarative syntax. It also encourages explicit expression of 'state' via property bindings. 7 | 8 | This makes the stopwatch example a perfect candidate for a SwiftUI experiment. The aim here is to develop the app in order to compare how the resutling code stacks up against imperative code typically written when working with UIKit. 9 | 10 | If successful, SwiftUI should prove (when comparing to imperative code): 11 | 12 | * It involves writting significantly less code overall 13 | * Incorrect states are lessly likely to happen 14 | * Readability is improved 15 | 16 | An additional observation we can make with this experiment is the performance of SwiftUI's rendering engine. The stopwatch's timer will be updating the UI every millisecond. The promise of SwiftUI is that it will efficiently take care of rendering on the programmer's behalf; diffing the view and only updating what it needs to. We may be able to observe this also. 17 | -------------------------------------------------------------------------------- /SwiftUIStopwatch.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 8C38807C22BF801100C87136 /* LapTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C38807B22BF801100C87136 /* LapTime.swift */; }; 11 | 8C3E7D5A22BA307200CE1DBA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C3E7D5922BA307200CE1DBA /* AppDelegate.swift */; }; 12 | 8C3E7D5C22BA307200CE1DBA /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C3E7D5B22BA307200CE1DBA /* SceneDelegate.swift */; }; 13 | 8C3E7D5E22BA307200CE1DBA /* StopwatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C3E7D5D22BA307200CE1DBA /* StopwatchView.swift */; }; 14 | 8C3E7D6022BA307300CE1DBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C3E7D5F22BA307300CE1DBA /* Assets.xcassets */; }; 15 | 8C3E7D6322BA307300CE1DBA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C3E7D6222BA307300CE1DBA /* Preview Assets.xcassets */; }; 16 | 8C3E7D6622BA307300CE1DBA /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8C3E7D6422BA307300CE1DBA /* LaunchScreen.storyboard */; }; 17 | 8C9D5ED422BB8ACA00FFEA9E /* TimeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9D5ED322BB8ACA00FFEA9E /* TimeController.swift */; }; 18 | 8C9D5ED622BBA61600FFEA9E /* TimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9D5ED522BBA61600FFEA9E /* TimeView.swift */; }; 19 | 8C9D5ED822BBAF2C00FFEA9E /* LapTimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9D5ED722BBAF2C00FFEA9E /* LapTimeRow.swift */; }; 20 | 8CAD122922BA46D600F7487C /* StopwatchControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAD122822BA46D600F7487C /* StopwatchControlsView.swift */; }; 21 | 8CAD122C22BA470C00F7487C /* Buttons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAD122B22BA470C00F7487C /* Buttons.swift */; }; 22 | 8CAD122E22BA475000F7487C /* Stopwatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CAD122D22BA475000F7487C /* Stopwatch.swift */; }; 23 | 8CF141A022BB8A3500982491 /* TimeInterval+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF1419F22BB8A3500982491 /* TimeInterval+Ext.swift */; }; 24 | /* End PBXBuildFile section */ 25 | 26 | /* Begin PBXFileReference section */ 27 | 8C38807B22BF801100C87136 /* LapTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LapTime.swift; sourceTree = ""; }; 28 | 8C3E7D5622BA307200CE1DBA /* SwiftUIStopwatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIStopwatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | 8C3E7D5922BA307200CE1DBA /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 30 | 8C3E7D5B22BA307200CE1DBA /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 31 | 8C3E7D5D22BA307200CE1DBA /* StopwatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopwatchView.swift; sourceTree = ""; }; 32 | 8C3E7D5F22BA307300CE1DBA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 33 | 8C3E7D6222BA307300CE1DBA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 34 | 8C3E7D6522BA307300CE1DBA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 35 | 8C3E7D6722BA307300CE1DBA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 36 | 8C9D5ED322BB8ACA00FFEA9E /* TimeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeController.swift; sourceTree = ""; }; 37 | 8C9D5ED522BBA61600FFEA9E /* TimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeView.swift; sourceTree = ""; }; 38 | 8C9D5ED722BBAF2C00FFEA9E /* LapTimeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LapTimeRow.swift; sourceTree = ""; }; 39 | 8CAD122822BA46D600F7487C /* StopwatchControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopwatchControlsView.swift; sourceTree = ""; }; 40 | 8CAD122B22BA470C00F7487C /* Buttons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buttons.swift; sourceTree = ""; }; 41 | 8CAD122D22BA475000F7487C /* Stopwatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stopwatch.swift; sourceTree = ""; }; 42 | 8CF1419F22BB8A3500982491 /* TimeInterval+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Ext.swift"; sourceTree = ""; }; 43 | /* End PBXFileReference section */ 44 | 45 | /* Begin PBXFrameworksBuildPhase section */ 46 | 8C3E7D5322BA307100CE1DBA /* Frameworks */ = { 47 | isa = PBXFrameworksBuildPhase; 48 | buildActionMask = 2147483647; 49 | files = ( 50 | ); 51 | runOnlyForDeploymentPostprocessing = 0; 52 | }; 53 | /* End PBXFrameworksBuildPhase section */ 54 | 55 | /* Begin PBXGroup section */ 56 | 8C38807A22BF800600C87136 /* Entities */ = { 57 | isa = PBXGroup; 58 | children = ( 59 | 8C38807B22BF801100C87136 /* LapTime.swift */, 60 | ); 61 | path = Entities; 62 | sourceTree = ""; 63 | }; 64 | 8C3E7D4D22BA307100CE1DBA = { 65 | isa = PBXGroup; 66 | children = ( 67 | 8C3E7D5822BA307200CE1DBA /* SwiftUIStopwatch */, 68 | 8C3E7D5722BA307200CE1DBA /* Products */, 69 | ); 70 | sourceTree = ""; 71 | }; 72 | 8C3E7D5722BA307200CE1DBA /* Products */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | 8C3E7D5622BA307200CE1DBA /* SwiftUIStopwatch.app */, 76 | ); 77 | name = Products; 78 | sourceTree = ""; 79 | }; 80 | 8C3E7D5822BA307200CE1DBA /* SwiftUIStopwatch */ = { 81 | isa = PBXGroup; 82 | children = ( 83 | 8C3E7D5922BA307200CE1DBA /* AppDelegate.swift */, 84 | 8C3E7D5B22BA307200CE1DBA /* SceneDelegate.swift */, 85 | 8C38807A22BF800600C87136 /* Entities */, 86 | 8CF1419D22BB8A0E00982491 /* Model */, 87 | 8CAD122522BA46B300F7487C /* Views */, 88 | 8CAD122A22BA46FD00F7487C /* Buttons */, 89 | 8CF141A122BB8A6A00982491 /* Utilities */, 90 | 8CF1419E22BB8A1C00982491 /* Extensions */, 91 | 8C3E7D5F22BA307300CE1DBA /* Assets.xcassets */, 92 | 8C3E7D6422BA307300CE1DBA /* LaunchScreen.storyboard */, 93 | 8C3E7D6722BA307300CE1DBA /* Info.plist */, 94 | 8C3E7D6122BA307300CE1DBA /* Preview Content */, 95 | ); 96 | path = SwiftUIStopwatch; 97 | sourceTree = ""; 98 | }; 99 | 8C3E7D6122BA307300CE1DBA /* Preview Content */ = { 100 | isa = PBXGroup; 101 | children = ( 102 | 8C3E7D6222BA307300CE1DBA /* Preview Assets.xcassets */, 103 | ); 104 | path = "Preview Content"; 105 | sourceTree = ""; 106 | }; 107 | 8CAD122522BA46B300F7487C /* Views */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | 8C3E7D5D22BA307200CE1DBA /* StopwatchView.swift */, 111 | 8CAD122822BA46D600F7487C /* StopwatchControlsView.swift */, 112 | 8C9D5ED522BBA61600FFEA9E /* TimeView.swift */, 113 | 8C9D5ED722BBAF2C00FFEA9E /* LapTimeRow.swift */, 114 | ); 115 | path = Views; 116 | sourceTree = ""; 117 | }; 118 | 8CAD122A22BA46FD00F7487C /* Buttons */ = { 119 | isa = PBXGroup; 120 | children = ( 121 | 8CAD122B22BA470C00F7487C /* Buttons.swift */, 122 | ); 123 | path = Buttons; 124 | sourceTree = ""; 125 | }; 126 | 8CF1419D22BB8A0E00982491 /* Model */ = { 127 | isa = PBXGroup; 128 | children = ( 129 | 8CAD122D22BA475000F7487C /* Stopwatch.swift */, 130 | ); 131 | path = Model; 132 | sourceTree = ""; 133 | }; 134 | 8CF1419E22BB8A1C00982491 /* Extensions */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | 8CF1419F22BB8A3500982491 /* TimeInterval+Ext.swift */, 138 | ); 139 | path = Extensions; 140 | sourceTree = ""; 141 | }; 142 | 8CF141A122BB8A6A00982491 /* Utilities */ = { 143 | isa = PBXGroup; 144 | children = ( 145 | 8C9D5ED322BB8ACA00FFEA9E /* TimeController.swift */, 146 | ); 147 | path = Utilities; 148 | sourceTree = ""; 149 | }; 150 | /* End PBXGroup section */ 151 | 152 | /* Begin PBXNativeTarget section */ 153 | 8C3E7D5522BA307100CE1DBA /* SwiftUIStopwatch */ = { 154 | isa = PBXNativeTarget; 155 | buildConfigurationList = 8C3E7D6A22BA307300CE1DBA /* Build configuration list for PBXNativeTarget "SwiftUIStopwatch" */; 156 | buildPhases = ( 157 | 8C3E7D5222BA307100CE1DBA /* Sources */, 158 | 8C3E7D5322BA307100CE1DBA /* Frameworks */, 159 | 8C3E7D5422BA307100CE1DBA /* Resources */, 160 | ); 161 | buildRules = ( 162 | ); 163 | dependencies = ( 164 | ); 165 | name = SwiftUIStopwatch; 166 | productName = SwiftUIStopwatch; 167 | productReference = 8C3E7D5622BA307200CE1DBA /* SwiftUIStopwatch.app */; 168 | productType = "com.apple.product-type.application"; 169 | }; 170 | /* End PBXNativeTarget section */ 171 | 172 | /* Begin PBXProject section */ 173 | 8C3E7D4E22BA307100CE1DBA /* Project object */ = { 174 | isa = PBXProject; 175 | attributes = { 176 | LastSwiftUpdateCheck = 1100; 177 | LastUpgradeCheck = 1100; 178 | ORGANIZATIONNAME = "Neil Smith"; 179 | TargetAttributes = { 180 | 8C3E7D5522BA307100CE1DBA = { 181 | CreatedOnToolsVersion = 11.0; 182 | }; 183 | }; 184 | }; 185 | buildConfigurationList = 8C3E7D5122BA307100CE1DBA /* Build configuration list for PBXProject "SwiftUIStopwatch" */; 186 | compatibilityVersion = "Xcode 9.3"; 187 | developmentRegion = en; 188 | hasScannedForEncodings = 0; 189 | knownRegions = ( 190 | en, 191 | Base, 192 | ); 193 | mainGroup = 8C3E7D4D22BA307100CE1DBA; 194 | productRefGroup = 8C3E7D5722BA307200CE1DBA /* Products */; 195 | projectDirPath = ""; 196 | projectRoot = ""; 197 | targets = ( 198 | 8C3E7D5522BA307100CE1DBA /* SwiftUIStopwatch */, 199 | ); 200 | }; 201 | /* End PBXProject section */ 202 | 203 | /* Begin PBXResourcesBuildPhase section */ 204 | 8C3E7D5422BA307100CE1DBA /* Resources */ = { 205 | isa = PBXResourcesBuildPhase; 206 | buildActionMask = 2147483647; 207 | files = ( 208 | 8C3E7D6622BA307300CE1DBA /* LaunchScreen.storyboard in Resources */, 209 | 8C3E7D6322BA307300CE1DBA /* Preview Assets.xcassets in Resources */, 210 | 8C3E7D6022BA307300CE1DBA /* Assets.xcassets in Resources */, 211 | ); 212 | runOnlyForDeploymentPostprocessing = 0; 213 | }; 214 | /* End PBXResourcesBuildPhase section */ 215 | 216 | /* Begin PBXSourcesBuildPhase section */ 217 | 8C3E7D5222BA307100CE1DBA /* Sources */ = { 218 | isa = PBXSourcesBuildPhase; 219 | buildActionMask = 2147483647; 220 | files = ( 221 | 8CAD122C22BA470C00F7487C /* Buttons.swift in Sources */, 222 | 8CAD122E22BA475000F7487C /* Stopwatch.swift in Sources */, 223 | 8C9D5ED422BB8ACA00FFEA9E /* TimeController.swift in Sources */, 224 | 8C3E7D5A22BA307200CE1DBA /* AppDelegate.swift in Sources */, 225 | 8C3E7D5C22BA307200CE1DBA /* SceneDelegate.swift in Sources */, 226 | 8C9D5ED622BBA61600FFEA9E /* TimeView.swift in Sources */, 227 | 8CF141A022BB8A3500982491 /* TimeInterval+Ext.swift in Sources */, 228 | 8C9D5ED822BBAF2C00FFEA9E /* LapTimeRow.swift in Sources */, 229 | 8C38807C22BF801100C87136 /* LapTime.swift in Sources */, 230 | 8CAD122922BA46D600F7487C /* StopwatchControlsView.swift in Sources */, 231 | 8C3E7D5E22BA307200CE1DBA /* StopwatchView.swift in Sources */, 232 | ); 233 | runOnlyForDeploymentPostprocessing = 0; 234 | }; 235 | /* End PBXSourcesBuildPhase section */ 236 | 237 | /* Begin PBXVariantGroup section */ 238 | 8C3E7D6422BA307300CE1DBA /* LaunchScreen.storyboard */ = { 239 | isa = PBXVariantGroup; 240 | children = ( 241 | 8C3E7D6522BA307300CE1DBA /* Base */, 242 | ); 243 | name = LaunchScreen.storyboard; 244 | sourceTree = ""; 245 | }; 246 | /* End PBXVariantGroup section */ 247 | 248 | /* Begin XCBuildConfiguration section */ 249 | 8C3E7D6822BA307300CE1DBA /* Debug */ = { 250 | isa = XCBuildConfiguration; 251 | buildSettings = { 252 | ALWAYS_SEARCH_USER_PATHS = NO; 253 | CLANG_ANALYZER_NONNULL = YES; 254 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 255 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 256 | CLANG_CXX_LIBRARY = "libc++"; 257 | CLANG_ENABLE_MODULES = YES; 258 | CLANG_ENABLE_OBJC_ARC = YES; 259 | CLANG_ENABLE_OBJC_WEAK = YES; 260 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 261 | CLANG_WARN_BOOL_CONVERSION = YES; 262 | CLANG_WARN_COMMA = YES; 263 | CLANG_WARN_CONSTANT_CONVERSION = YES; 264 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 265 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 266 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 267 | CLANG_WARN_EMPTY_BODY = YES; 268 | CLANG_WARN_ENUM_CONVERSION = YES; 269 | CLANG_WARN_INFINITE_RECURSION = YES; 270 | CLANG_WARN_INT_CONVERSION = YES; 271 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 272 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 273 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 274 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 275 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 276 | CLANG_WARN_STRICT_PROTOTYPES = YES; 277 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 278 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 279 | CLANG_WARN_UNREACHABLE_CODE = YES; 280 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 281 | COPY_PHASE_STRIP = NO; 282 | DEBUG_INFORMATION_FORMAT = dwarf; 283 | ENABLE_STRICT_OBJC_MSGSEND = YES; 284 | ENABLE_TESTABILITY = YES; 285 | GCC_C_LANGUAGE_STANDARD = gnu11; 286 | GCC_DYNAMIC_NO_PIC = NO; 287 | GCC_NO_COMMON_BLOCKS = YES; 288 | GCC_OPTIMIZATION_LEVEL = 0; 289 | GCC_PREPROCESSOR_DEFINITIONS = ( 290 | "DEBUG=1", 291 | "$(inherited)", 292 | ); 293 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 294 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 295 | GCC_WARN_UNDECLARED_SELECTOR = YES; 296 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 297 | GCC_WARN_UNUSED_FUNCTION = YES; 298 | GCC_WARN_UNUSED_VARIABLE = YES; 299 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 300 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 301 | MTL_FAST_MATH = YES; 302 | ONLY_ACTIVE_ARCH = YES; 303 | SDKROOT = iphoneos; 304 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 305 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 306 | }; 307 | name = Debug; 308 | }; 309 | 8C3E7D6922BA307300CE1DBA /* Release */ = { 310 | isa = XCBuildConfiguration; 311 | buildSettings = { 312 | ALWAYS_SEARCH_USER_PATHS = NO; 313 | CLANG_ANALYZER_NONNULL = YES; 314 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 315 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 316 | CLANG_CXX_LIBRARY = "libc++"; 317 | CLANG_ENABLE_MODULES = YES; 318 | CLANG_ENABLE_OBJC_ARC = YES; 319 | CLANG_ENABLE_OBJC_WEAK = YES; 320 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 321 | CLANG_WARN_BOOL_CONVERSION = YES; 322 | CLANG_WARN_COMMA = YES; 323 | CLANG_WARN_CONSTANT_CONVERSION = YES; 324 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 325 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 326 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 327 | CLANG_WARN_EMPTY_BODY = YES; 328 | CLANG_WARN_ENUM_CONVERSION = YES; 329 | CLANG_WARN_INFINITE_RECURSION = YES; 330 | CLANG_WARN_INT_CONVERSION = YES; 331 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 332 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 333 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 334 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 335 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 336 | CLANG_WARN_STRICT_PROTOTYPES = YES; 337 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 338 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 339 | CLANG_WARN_UNREACHABLE_CODE = YES; 340 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 341 | COPY_PHASE_STRIP = NO; 342 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 343 | ENABLE_NS_ASSERTIONS = NO; 344 | ENABLE_STRICT_OBJC_MSGSEND = YES; 345 | GCC_C_LANGUAGE_STANDARD = gnu11; 346 | GCC_NO_COMMON_BLOCKS = YES; 347 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 348 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 349 | GCC_WARN_UNDECLARED_SELECTOR = YES; 350 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 351 | GCC_WARN_UNUSED_FUNCTION = YES; 352 | GCC_WARN_UNUSED_VARIABLE = YES; 353 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 354 | MTL_ENABLE_DEBUG_INFO = NO; 355 | MTL_FAST_MATH = YES; 356 | SDKROOT = iphoneos; 357 | SWIFT_COMPILATION_MODE = wholemodule; 358 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 359 | VALIDATE_PRODUCT = YES; 360 | }; 361 | name = Release; 362 | }; 363 | 8C3E7D6B22BA307300CE1DBA /* Debug */ = { 364 | isa = XCBuildConfiguration; 365 | buildSettings = { 366 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 367 | CODE_SIGN_STYLE = Automatic; 368 | DEVELOPMENT_ASSET_PATHS = "SwiftUIStopwatch/Preview\\ Content"; 369 | ENABLE_PREVIEWS = YES; 370 | INFOPLIST_FILE = SwiftUIStopwatch/Info.plist; 371 | LD_RUNPATH_SEARCH_PATHS = ( 372 | "$(inherited)", 373 | "@executable_path/Frameworks", 374 | ); 375 | PRODUCT_BUNDLE_IDENTIFIER = com.NeilSmithDesignLTD.SwiftUIStopwatch; 376 | PRODUCT_NAME = "$(TARGET_NAME)"; 377 | SWIFT_VERSION = 5.0; 378 | TARGETED_DEVICE_FAMILY = "1,2"; 379 | }; 380 | name = Debug; 381 | }; 382 | 8C3E7D6C22BA307300CE1DBA /* Release */ = { 383 | isa = XCBuildConfiguration; 384 | buildSettings = { 385 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 386 | CODE_SIGN_STYLE = Automatic; 387 | DEVELOPMENT_ASSET_PATHS = "SwiftUIStopwatch/Preview\\ Content"; 388 | ENABLE_PREVIEWS = YES; 389 | INFOPLIST_FILE = SwiftUIStopwatch/Info.plist; 390 | LD_RUNPATH_SEARCH_PATHS = ( 391 | "$(inherited)", 392 | "@executable_path/Frameworks", 393 | ); 394 | PRODUCT_BUNDLE_IDENTIFIER = com.NeilSmithDesignLTD.SwiftUIStopwatch; 395 | PRODUCT_NAME = "$(TARGET_NAME)"; 396 | SWIFT_VERSION = 5.0; 397 | TARGETED_DEVICE_FAMILY = "1,2"; 398 | }; 399 | name = Release; 400 | }; 401 | /* End XCBuildConfiguration section */ 402 | 403 | /* Begin XCConfigurationList section */ 404 | 8C3E7D5122BA307100CE1DBA /* Build configuration list for PBXProject "SwiftUIStopwatch" */ = { 405 | isa = XCConfigurationList; 406 | buildConfigurations = ( 407 | 8C3E7D6822BA307300CE1DBA /* Debug */, 408 | 8C3E7D6922BA307300CE1DBA /* Release */, 409 | ); 410 | defaultConfigurationIsVisible = 0; 411 | defaultConfigurationName = Release; 412 | }; 413 | 8C3E7D6A22BA307300CE1DBA /* Build configuration list for PBXNativeTarget "SwiftUIStopwatch" */ = { 414 | isa = XCConfigurationList; 415 | buildConfigurations = ( 416 | 8C3E7D6B22BA307300CE1DBA /* Debug */, 417 | 8C3E7D6C22BA307300CE1DBA /* Release */, 418 | ); 419 | defaultConfigurationIsVisible = 0; 420 | defaultConfigurationName = Release; 421 | }; 422 | /* End XCConfigurationList section */ 423 | }; 424 | rootObject = 8C3E7D4E22BA307100CE1DBA /* Project object */; 425 | } 426 | -------------------------------------------------------------------------------- /SwiftUIStopwatch.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SwiftUIStopwatch.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SwiftUIStopwatch.xcodeproj/xcshareddata/xcschemes/SwiftUIStopwatch.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 19/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | // Override point for customization after application launch. 18 | return true 19 | } 20 | 21 | func applicationWillTerminate(_ application: UIApplication) { 22 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 23 | } 24 | 25 | // MARK: UISceneSession Lifecycle 26 | 27 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 28 | // Called when a new scene session is being created. 29 | // Use this method to select a configuration to create the new scene with. 30 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 31 | } 32 | 33 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 34 | // Called when the user discards a scene session. 35 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 36 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 37 | } 38 | 39 | 40 | } 41 | 42 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/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 | } -------------------------------------------------------------------------------- /SwiftUIStopwatch/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftUIStopwatch/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Buttons/Buttons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Buttons.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 19/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LapResetButton: View { 12 | 13 | 14 | // MARK: Binding 15 | @Binding var mode: Stopwatch.Mode 16 | private var isActive: Bool { 17 | return mode != .inactive 18 | } 19 | 20 | 21 | // MARK: Body 22 | var body: some View { 23 | Button(action: onTap) { 24 | CircleView( 25 | text: text, 26 | textColor: textColor, 27 | foregroundColor: color, 28 | opacity: 1 29 | ) 30 | }.disabled(!isActive) 31 | } 32 | 33 | 34 | // MARK: Action 35 | var action: (() -> Void) 36 | private func onTap() { 37 | guard isActive else { return } 38 | self.action() 39 | } 40 | 41 | 42 | // MARK: Color 43 | private var color: Color { 44 | isActive ? activeColor : inactiveColor 45 | } 46 | private let activeColor: Color = .gray 47 | private let inactiveColor: Color = Color.gray.opacity(0.2) 48 | 49 | 50 | // MARK: Text 51 | private var text: String { 52 | mode == .paused ? "Reset" : "Lap" 53 | } 54 | private var textColor: Color { 55 | isActive ? Color.white : Color.white.opacity(0.5) 56 | } 57 | 58 | } 59 | 60 | 61 | struct StartStopButton: View { 62 | 63 | 64 | // MARK: Binding 65 | @Binding var mode: Stopwatch.Mode 66 | 67 | 68 | // MARK: Body 69 | var body: some View { 70 | Button(action: action) { 71 | CircleView( 72 | text: text, 73 | textColor: textColor, 74 | foregroundColor: color, 75 | opacity: 0.5 76 | ) 77 | } 78 | } 79 | 80 | 81 | // MARK: Action 82 | var action: (() -> Void) 83 | 84 | 85 | // MARK: Color 86 | private var color: Color { 87 | mode == .running ? .red : .green 88 | } 89 | 90 | 91 | // MARK: Text 92 | private var text: String { 93 | mode == .running ? "Stop" : "Start" 94 | } 95 | private var textColor: Color { 96 | mode == .running ? .red : .green 97 | } 98 | 99 | } 100 | 101 | 102 | struct CircleView: View { 103 | 104 | var text: String 105 | var textColor: Color 106 | var foregroundColor: Color 107 | var opacity: Double 108 | private var fillColor: Color { 109 | foregroundColor.opacity(opacity) 110 | } 111 | 112 | var body: some View { 113 | ZStack { 114 | Circle().fill(fillColor).frame(width: 80, height: 80, alignment: .center) 115 | Text(text).color(self.textColor) 116 | } 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Entities/LapTime.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LapTime.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 23/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LapTime: Identifiable { 12 | var id: Int 13 | var value: TimeInterval 14 | var ranking: Ranking 15 | 16 | enum Ranking: String { 17 | case other 18 | case shortest 19 | case longest 20 | var color: Color { 21 | switch self { 22 | case .other: return .white 23 | case .shortest: return .green 24 | case .longest: return .red 25 | } 26 | } 27 | } 28 | 29 | } 30 | 31 | 32 | extension Array where Element == LapTime { 33 | 34 | /// Traverses the array of lap times to find the shortest and longest time 35 | /// and mutates in-place the 'ranking' property of each appropriately. 36 | /// Only applicable when there are 2 or more 'recorded' lap times. 37 | mutating func analyse() { 38 | 39 | guard self.count > 2 else { return } 40 | let recorded = Array(self.dropFirst()) 41 | let shortest = recorded.min(by: { $0.value < $1.value }) 42 | let longest = recorded.max(by: { $0.value < $1.value }) 43 | var shortestIndex: Int? 44 | var longestIndex: Int? 45 | if let s = shortest, let index = self.firstIndex(where: { $0.value == s.value }) { 46 | shortestIndex = index 47 | } 48 | if let l = longest, let index = self.firstIndex(where: { $0.value == l.value }) { 49 | longestIndex = index 50 | } 51 | for i in 0.. Int { 26 | let n = timeInterval / self.divisor 27 | return Int(n) 28 | } 29 | } 30 | 31 | func values(_ components: Set) -> [Component : Int] { 32 | var values: [Component : Int] = [:] 33 | var remainingTime = self 34 | for comp in components.sorted(by: { $0.rawValue < $1.rawValue }) { 35 | let v = comp.value(for: remainingTime) 36 | values[comp] = v 37 | remainingTime -= TimeInterval(v) * comp.divisor 38 | } 39 | return values 40 | } 41 | 42 | static func formatted(for values: [Component : Int]) -> [Component : String] { 43 | var strings: [Component : String] = [:] 44 | if let m = TimeInterval.getString(from: .minutes, for: values) { 45 | strings[.minutes] = m 46 | } 47 | if let s = TimeInterval.getString(from: .seconds, for: values) { 48 | strings[.seconds] = s 49 | } 50 | if let ms = TimeInterval.getString(from: .milliseconds, for: values) { 51 | strings[.milliseconds] = ms 52 | } 53 | return strings 54 | } 55 | 56 | private static func getString(from comp: Component, for values: [Component : Int]) -> String? { 57 | guard var n = values[comp] else { return nil } 58 | if comp == .milliseconds { 59 | n = n / 10 60 | } 61 | return n < 10 ? "0\(n)" : "\(n)" 62 | } 63 | 64 | var minutes: String { 65 | let strings = TimeInterval.formatted(for: self.values([.minutes, .seconds, .milliseconds])) 66 | return strings[.minutes]! 67 | } 68 | var seconds: String { 69 | let strings = TimeInterval.formatted(for: self.values([.minutes, .seconds, .milliseconds])) 70 | return strings[.seconds]! 71 | } 72 | var milliseconds: String { 73 | let strings = TimeInterval.formatted(for: self.values([.minutes, .seconds, .milliseconds])) 74 | return strings[.milliseconds]! 75 | } 76 | 77 | 78 | } 79 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/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 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UILaunchStoryboardName 33 | LaunchScreen 34 | UISceneConfigurationName 35 | Default Configuration 36 | UISceneDelegateClassName 37 | $(PRODUCT_MODULE_NAME).SceneDelegate 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Model/Stopwatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stopwatch.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 19/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import Combine 12 | 13 | final class Stopwatch: BindableObject { 14 | 15 | 16 | // MARK: Binding 17 | var didChange = PassthroughSubject() 18 | 19 | private func notify() { 20 | didChange.send(()) 21 | } 22 | 23 | 24 | // MARK: State 25 | private (set) var mode: Mode = .inactive 26 | 27 | 28 | // MARK: Data 29 | private (set) var elapsedTime: TimeInterval = 0 30 | private (set) var lapTimes: [LapTime] = [] 31 | var currentLapTime: TimeInterval { 32 | guard let lapTime = lapTimes.first else { return 0 } 33 | return lapTime.value 34 | } 35 | 36 | 37 | // MARK: Utilities 38 | private lazy var timer: TimeController = { 39 | return TimeController(mode: .stopwatch) { [weak self] time in 40 | self?.update(for: time) 41 | } 42 | }() 43 | private lazy var lapTimer: TimeController = { 44 | return TimeController(mode: .stopwatch) { [weak self] time in 45 | self?.update(forLap: time) 46 | } 47 | }() 48 | 49 | } 50 | 51 | 52 | // MARK: - Interface 53 | extension Stopwatch { 54 | 55 | func startOrStop() { 56 | switch mode { 57 | case .inactive: start() 58 | case .running: pause() 59 | case .paused: start() 60 | } 61 | } 62 | 63 | func recordLapTimeOrReset() { 64 | switch mode { 65 | case .inactive: return 66 | case .running: recordLapTime() 67 | case .paused: reset() 68 | } 69 | } 70 | 71 | } 72 | 73 | 74 | // MARK: - User interactions 75 | extension Stopwatch { 76 | 77 | private func start() { 78 | mode = .running 79 | timer.start() 80 | startNewLapTimeIfNeeded() 81 | notify() 82 | } 83 | 84 | private func pause() { 85 | mode = .paused 86 | timer.pause() 87 | lapTimer.pause() 88 | notify() 89 | } 90 | 91 | private func reset() { 92 | mode = .inactive 93 | timer.reset() 94 | lapTimer.reset() 95 | lapTimes.removeAll() 96 | notify() 97 | } 98 | 99 | private func recordLapTime() { 100 | startNewLapTime() 101 | notify() 102 | } 103 | 104 | } 105 | 106 | // MARK: - Helpers 107 | extension Stopwatch { 108 | 109 | private func startNewLapTimeIfNeeded() { 110 | if lapTimes.isEmpty { 111 | startNewLapTime() 112 | } else { 113 | lapTimer.resume() 114 | } 115 | } 116 | 117 | private func startNewLapTime() { 118 | let id = lapTimes.count + 1 119 | let lapTime = LapTime(id: id, value: 0, ranking: .other) 120 | lapTimes.insert(lapTime, at: 0) 121 | lapTimer.reset() 122 | lapTimer.start() 123 | lapTimes.analyse() 124 | } 125 | 126 | private func update(for newTime: TimeInterval) { 127 | elapsedTime = newTime 128 | notify() 129 | } 130 | 131 | private func update(forLap time: TimeInterval) { 132 | guard !lapTimes.isEmpty else { return } 133 | lapTimes[0].value = time 134 | notify() 135 | } 136 | 137 | } 138 | 139 | 140 | // MARK: - Types 141 | extension Stopwatch { 142 | 143 | enum Mode: String { 144 | case inactive 145 | case running 146 | case paused 147 | } 148 | 149 | } 150 | 151 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftUIStopwatch/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 19/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | /// The single source of truth for this app's model 17 | private var stopwatch: Stopwatch = Stopwatch() 18 | 19 | 20 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 21 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 22 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 23 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 24 | 25 | // Use a UIHostingController as window root view controller 26 | if let windowScene = scene as? UIWindowScene { 27 | let window = UIWindow(windowScene: windowScene) 28 | let rootView = StopwatchView(stopwatch: self.stopwatch) 29 | window.rootViewController = UIHostingController(rootView: rootView) 30 | self.window = window 31 | window.makeKeyAndVisible() 32 | } 33 | } 34 | 35 | func sceneDidDisconnect(_ scene: UIScene) { 36 | // Called as the scene is being released by the system. 37 | // This occurs shortly after the scene enters the background, or when its session is discarded. 38 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 39 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 40 | } 41 | 42 | func sceneDidBecomeActive(_ scene: UIScene) { 43 | // Called when the scene has moved from an inactive state to an active state. 44 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 45 | } 46 | 47 | func sceneWillResignActive(_ scene: UIScene) { 48 | // Called when the scene will move from an active state to an inactive state. 49 | // This may occur due to temporary interruptions (ex. an incoming phone call). 50 | } 51 | 52 | func sceneWillEnterForeground(_ scene: UIScene) { 53 | // Called as the scene transitions from the background to the foreground. 54 | // Use this method to undo the changes made on entering the background. 55 | } 56 | 57 | func sceneDidEnterBackground(_ scene: UIScene) { 58 | // Called as the scene transitions from the foreground to the background. 59 | // Use this method to save data, release shared resources, and store enough scene-specific state information 60 | // to restore the scene back to its current state. 61 | } 62 | 63 | 64 | } 65 | 66 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Utilities/TimeController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeController.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 20/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import QuartzCore 11 | 12 | public final class TimeController: NSObject { 13 | 14 | 15 | // MARK: Interface 16 | public var isActive: Bool { 17 | return displayLink != nil 18 | } 19 | 20 | public func start(mode: Mode? = nil) { 21 | guard let mode = mode else { 22 | resume() 23 | return 24 | } 25 | self.mode = mode 26 | switch mode { 27 | case .stopwatch: totalElapsedTime = 0.0 28 | case .timer(startTime: let time): totalElapsedTime = time 29 | } 30 | } 31 | 32 | public func pause() { 33 | displayLink?.invalidate() 34 | } 35 | 36 | public func resume() { 37 | activateDisplayLink() 38 | } 39 | 40 | public func reset(deactivate: Bool = true) { 41 | displayLink?.invalidate() 42 | totalElapsedTime = 0.0 43 | callback(totalElapsedTime) 44 | if deactivate { 45 | displayLink = nil 46 | } 47 | } 48 | 49 | public init(mode: Mode, callback: @escaping (TimeInterval) -> Void) { 50 | self.mode = mode 51 | self.callback = callback 52 | switch mode { 53 | case .stopwatch: totalElapsedTime = 0.0 54 | case .timer(startTime: let time): totalElapsedTime = time 55 | } 56 | super.init() 57 | } 58 | 59 | public enum Mode { 60 | case stopwatch 61 | case timer(startTime: TimeInterval) 62 | } 63 | 64 | 65 | // MARK: Private properties 66 | private var startTime: TimeInterval = 0.0 67 | private var elapsedTime: TimeInterval = 0.0 68 | private var totalElapsedTime: TimeInterval 69 | 70 | private var displayLink: CADisplayLink? = nil 71 | private var callback: (TimeInterval) -> Void 72 | private var mode: Mode 73 | 74 | 75 | deinit { 76 | displayLink?.invalidate() 77 | displayLink = nil 78 | } 79 | 80 | private func activateDisplayLink() { 81 | startTime = CACurrentMediaTime() 82 | displayLink = nil 83 | displayLink = CADisplayLink(target: self, selector: #selector(updateTime)) 84 | displayLink?.add(to: .main, forMode: .common) 85 | displayLink?.isPaused = false 86 | } 87 | 88 | @objc private func updateTime() { 89 | elapsedTime = CACurrentMediaTime() - startTime 90 | switch mode { 91 | case .stopwatch: 92 | totalElapsedTime += elapsedTime 93 | callback(totalElapsedTime) 94 | case .timer(startTime: _): 95 | totalElapsedTime -= elapsedTime 96 | guard totalElapsedTime > 0 else { return } 97 | callback(totalElapsedTime) 98 | } 99 | startTime = CACurrentMediaTime() 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Views/LapTimeRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LapTimeRow.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 20/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LapTimeRow: View { 12 | 13 | let lapTime: LapTime 14 | 15 | var timeText: String { 16 | return "\(lapTime.value.minutes):\(lapTime.value.seconds).\(lapTime.value.milliseconds)" 17 | } 18 | 19 | var body: some View { 20 | HStack { 21 | Text("Lap \(lapTime.id)").color(lapTime.ranking.color) 22 | Spacer() 23 | TimeView(time: lapTime.value, textColor: lapTime.ranking.color, fontSize: 17, fontWeight: .regular).frame(width: 104) 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Views/StopwatchControlsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StopwatchControlsView.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 19/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StopwatchControlsView : View { 12 | 13 | @Binding var mode: Stopwatch.Mode 14 | 15 | var onTapLapReset: () -> Void 16 | var onTapStartStop: () -> Void 17 | 18 | var body: some View { 19 | HStack { 20 | LapResetButton(mode: $mode, action: onTapLapReset) 21 | Spacer() 22 | StartStopButton(mode: $mode, action: onTapStartStop) 23 | } 24 | .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Views/StopwatchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StopwatchView.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 19/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StopwatchView : View { 12 | 13 | @ObjectBinding var stopwatch: Stopwatch 14 | 15 | var body: some View { 16 | NavigationView { 17 | VStack { 18 | Top(stopwatch: self.stopwatch) 19 | Bottom(stopwatch: self.stopwatch) 20 | } 21 | .navigationBarTitle(Text("Stopwatch"), displayMode: .inline) 22 | }.environment(\.colorScheme, .dark) 23 | } 24 | 25 | } 26 | 27 | 28 | // MARK: Subviews 29 | extension StopwatchView { 30 | 31 | private struct Top: View { 32 | 33 | @ObjectBinding var stopwatch: Stopwatch 34 | 35 | var body: some View { 36 | VStack { 37 | Spacer() 38 | TimeView(time: stopwatch.elapsedTime, textColor: .white, fontSize: 80, fontWeight: .thin) 39 | Spacer() 40 | StopwatchControlsView( 41 | mode: $stopwatch.mode, 42 | onTapLapReset: self.stopwatch.recordLapTimeOrReset, 43 | onTapStartStop: self.stopwatch.startOrStop 44 | ) 45 | Spacer() 46 | }.aspectRatio(1, contentMode: .fill) 47 | } 48 | 49 | } 50 | 51 | private struct Bottom: View { 52 | 53 | @ObjectBinding var stopwatch: Stopwatch 54 | 55 | var body: some View { 56 | List(stopwatch.lapTimes.identified(by: \.id)) { lapTime in 57 | LapTimeRow(lapTime: lapTime) 58 | } 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /SwiftUIStopwatch/Views/TimeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeView.swift 3 | // SwiftUIStopwatch 4 | // 5 | // Created by Neil Smith on 20/06/2019. 6 | // Copyright © 2019 Neil Smith. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TimeView: View { 12 | 13 | var time: TimeInterval 14 | let textColor: Color 15 | let fontSize: CGFloat 16 | let fontWeight: Font.Weight 17 | 18 | var body: some View { 19 | HStack(alignment: .lastTextBaseline, spacing: 0) { 20 | VStack { DigitView(text: time.minutes, textColor: textColor, fontSize: fontSize, fontWeight: fontWeight) }.frame(minWidth: 0, maxWidth: .infinity) 21 | VStack { TimeText(text: ":", textColor: textColor, fontSize: fontSize, fontWeight: fontWeight) } 22 | VStack { DigitView(text: time.seconds, textColor: textColor, fontSize: fontSize, fontWeight: fontWeight) }.frame(minWidth: 0, maxWidth: .infinity) 23 | VStack { TimeText(text: ".", textColor: textColor, fontSize: fontSize, fontWeight: fontWeight) } 24 | VStack { DigitView(text: time.milliseconds, textColor: textColor, fontSize: fontSize, fontWeight: fontWeight) }.frame(minWidth: 0, maxWidth: .infinity) 25 | }.padding([.leading, .trailing], 16) 26 | } 27 | 28 | } 29 | 30 | extension TimeView { 31 | 32 | private struct DigitView: View { 33 | 34 | let text: String 35 | let textColor: Color 36 | let fontSize: CGFloat 37 | let fontWeight: Font.Weight 38 | private var first: String { String(text.first!) } 39 | private var last: String { String(text.last!) } 40 | 41 | var body: some View { 42 | HStack(spacing: 0) { 43 | VStack { TimeText(text: first, textColor: textColor, fontSize: fontSize, fontWeight: fontWeight) }.frame(minWidth: 0, maxWidth: .infinity) 44 | VStack { TimeText(text: last, textColor: textColor, fontSize: fontSize, fontWeight: fontWeight) }.frame(minWidth: 0, maxWidth: .infinity) 45 | } 46 | } 47 | } 48 | 49 | private struct TimeText: View { 50 | 51 | let text: String 52 | let textColor: Color 53 | let fontSize: CGFloat 54 | let fontWeight: Font.Weight 55 | 56 | var body: some View { 57 | Text(text).color(textColor).font(.system(size: fontSize)).fontWeight(fontWeight).scaledToFit() 58 | } 59 | } 60 | 61 | } 62 | --------------------------------------------------------------------------------