├── README.md ├── SimpleImageViewer.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── gualtierofrigerio.xcuserdatad │ │ └── UserInterfaceState.xcuserstate └── xcuserdata │ └── gualtierofrigerio.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist └── SimpleImageViewer ├── AppCoordinator.swift ├── AppDelegate.swift ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ └── Contents.json └── Contents.json ├── Base.lproj └── Main.storyboard ├── Info.plist ├── Model ├── DetailImageViewModel.swift ├── DetailVideoViewModel.swift ├── FileEntry.swift ├── FilesViewModel.swift ├── SingleEntryViewModel.swift └── SingleImageViewModel.swift ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── SimpleImageViewer.entitlements ├── Utils ├── DropUtils.swift ├── FavoritesManager.swift ├── FilesystemManager.swift ├── ImageLoader.swift ├── ImageUtils.swift ├── MenuCommandsHandler.swift └── ThumbnailLoader.swift └── Views ├── ContentView.swift ├── DetailImageView.swift ├── DetailVideoView.swift ├── ExecuteClosure.swift ├── FilesView.swift ├── ImageView.swift ├── ImageViewLazy.swift ├── MenuCommands.swift ├── SingleEntryView.swift ├── SingleImageView.swift └── ThumbnailView.swift /README.md: -------------------------------------------------------------------------------- 1 | This is a sample project to build a macOS app in SwiftUI to display images 2 | 3 | See my blog post for details http://www.gfrigerio.com/build-a-macos-app-with-swiftui/ 4 | -------------------------------------------------------------------------------- /SimpleImageViewer.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 2586136325DEA80A00941A46 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2586136225DEA80A00941A46 /* AppCoordinator.swift */; }; 11 | 258A1EFB26D3D9EF00B92BDE /* ThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258A1EFA26D3D9EF00B92BDE /* ThumbnailView.swift */; }; 12 | 258A1EFD26D3DFB900B92BDE /* ThumbnailLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258A1EFC26D3DFB900B92BDE /* ThumbnailLoader.swift */; }; 13 | 258D1E6027301B4900B4A85F /* DetailVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258D1E5F27301B4900B4A85F /* DetailVideoView.swift */; }; 14 | 258D1E622730223B00B4A85F /* DetailVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258D1E612730223B00B4A85F /* DetailVideoViewModel.swift */; }; 15 | 2595AA1725E6A9CF007D50D7 /* ImageViewLazy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2595AA1625E6A9CF007D50D7 /* ImageViewLazy.swift */; }; 16 | 25A940F025CB00A200689556 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A940EF25CB00A200689556 /* AppDelegate.swift */; }; 17 | 25A940F225CB00A200689556 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A940F125CB00A200689556 /* ContentView.swift */; }; 18 | 25A940F425CB00A200689556 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 25A940F325CB00A200689556 /* Assets.xcassets */; }; 19 | 25A940F725CB00A200689556 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 25A940F625CB00A200689556 /* Preview Assets.xcassets */; }; 20 | 25A940FA25CB00A200689556 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 25A940F825CB00A200689556 /* Main.storyboard */; }; 21 | 25A9410425CB048700689556 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A9410325CB048700689556 /* ImageView.swift */; }; 22 | 25A9410825CB053C00689556 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A9410725CB053C00689556 /* ImageLoader.swift */; }; 23 | 25AF256725CD7E64003CE640 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AF256625CD7E64003CE640 /* FilesView.swift */; }; 24 | 25AF256A25CD7EE1003CE640 /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AF256925CD7EE1003CE640 /* FileEntry.swift */; }; 25 | 25AF257025CD801E003CE640 /* FilesystemManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AF256F25CD801E003CE640 /* FilesystemManager.swift */; }; 26 | 25AF257325CD81E6003CE640 /* DropUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AF257225CD81E6003CE640 /* DropUtils.swift */; }; 27 | 25AF257725CD87EB003CE640 /* FilesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AF257625CD87EB003CE640 /* FilesViewModel.swift */; }; 28 | 25AF257B25CD8D71003CE640 /* SingleImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AF257A25CD8D71003CE640 /* SingleImageView.swift */; }; 29 | 25B3563325D177B900A451B6 /* MenuCommandsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25B3563225D177B900A451B6 /* MenuCommandsHandler.swift */; }; 30 | 25B3563625D17A9A00A451B6 /* MenuCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25B3563525D17A9A00A451B6 /* MenuCommands.swift */; }; 31 | 25B3563C25D185D100A451B6 /* SingleEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25B3563B25D185D100A451B6 /* SingleEntryView.swift */; }; 32 | 25B3564025D1934800A451B6 /* ImageUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25B3563F25D1934800A451B6 /* ImageUtils.swift */; }; 33 | 25C72A4526D94517000266D7 /* DetailImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C72A4426D94517000266D7 /* DetailImageViewModel.swift */; }; 34 | 25C72A4726D94627000266D7 /* DetailImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C72A4626D94627000266D7 /* DetailImageView.swift */; }; 35 | 25CD27E125D81DE4009B0EB5 /* FavoritesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25CD27E025D81DE4009B0EB5 /* FavoritesManager.swift */; }; 36 | 25D7A6582789E022009A2005 /* ExecuteClosure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D7A6572789E022009A2005 /* ExecuteClosure.swift */; }; 37 | 25DF0AC826EBABB000188575 /* SingleEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25DF0AC726EBABB000188575 /* SingleEntryViewModel.swift */; }; 38 | 25E065C326F4F9F0005490BC /* SingleImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E065C226F4F9F0005490BC /* SingleImageViewModel.swift */; }; 39 | /* End PBXBuildFile section */ 40 | 41 | /* Begin PBXFileReference section */ 42 | 2586136225DEA80A00941A46 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 43 | 258A1EFA26D3D9EF00B92BDE /* ThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailView.swift; sourceTree = ""; }; 44 | 258A1EFC26D3DFB900B92BDE /* ThumbnailLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailLoader.swift; sourceTree = ""; }; 45 | 258D1E5F27301B4900B4A85F /* DetailVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailVideoView.swift; sourceTree = ""; }; 46 | 258D1E612730223B00B4A85F /* DetailVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailVideoViewModel.swift; sourceTree = ""; }; 47 | 2595AA1625E6A9CF007D50D7 /* ImageViewLazy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewLazy.swift; sourceTree = ""; }; 48 | 25A940EC25CB00A200689556 /* SimpleImageViewer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleImageViewer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 49 | 25A940EF25CB00A200689556 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 50 | 25A940F125CB00A200689556 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 51 | 25A940F325CB00A200689556 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 52 | 25A940F625CB00A200689556 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 53 | 25A940F925CB00A200689556 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 54 | 25A940FB25CB00A200689556 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55 | 25A940FC25CB00A200689556 /* SimpleImageViewer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SimpleImageViewer.entitlements; sourceTree = ""; }; 56 | 25A9410325CB048700689556 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; 57 | 25A9410725CB053C00689556 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; 58 | 25AF256625CD7E64003CE640 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = ""; }; 59 | 25AF256925CD7EE1003CE640 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = ""; }; 60 | 25AF256F25CD801E003CE640 /* FilesystemManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesystemManager.swift; sourceTree = ""; }; 61 | 25AF257225CD81E6003CE640 /* DropUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropUtils.swift; sourceTree = ""; }; 62 | 25AF257625CD87EB003CE640 /* FilesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesViewModel.swift; sourceTree = ""; }; 63 | 25AF257A25CD8D71003CE640 /* SingleImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleImageView.swift; sourceTree = ""; }; 64 | 25B3563225D177B900A451B6 /* MenuCommandsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuCommandsHandler.swift; sourceTree = ""; }; 65 | 25B3563525D17A9A00A451B6 /* MenuCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuCommands.swift; sourceTree = ""; }; 66 | 25B3563B25D185D100A451B6 /* SingleEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleEntryView.swift; sourceTree = ""; }; 67 | 25B3563F25D1934800A451B6 /* ImageUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUtils.swift; sourceTree = ""; }; 68 | 25C72A4426D94517000266D7 /* DetailImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailImageViewModel.swift; sourceTree = ""; }; 69 | 25C72A4626D94627000266D7 /* DetailImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailImageView.swift; sourceTree = ""; }; 70 | 25CD27E025D81DE4009B0EB5 /* FavoritesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesManager.swift; sourceTree = ""; }; 71 | 25D7A6572789E022009A2005 /* ExecuteClosure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecuteClosure.swift; sourceTree = ""; }; 72 | 25DF0AC726EBABB000188575 /* SingleEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleEntryViewModel.swift; sourceTree = ""; }; 73 | 25E065C226F4F9F0005490BC /* SingleImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleImageViewModel.swift; sourceTree = ""; }; 74 | /* End PBXFileReference section */ 75 | 76 | /* Begin PBXFrameworksBuildPhase section */ 77 | 25A940E925CB00A200689556 /* Frameworks */ = { 78 | isa = PBXFrameworksBuildPhase; 79 | buildActionMask = 2147483647; 80 | files = ( 81 | ); 82 | runOnlyForDeploymentPostprocessing = 0; 83 | }; 84 | /* End PBXFrameworksBuildPhase section */ 85 | 86 | /* Begin PBXGroup section */ 87 | 25A940E325CB00A200689556 = { 88 | isa = PBXGroup; 89 | children = ( 90 | 25A940EE25CB00A200689556 /* SimpleImageViewer */, 91 | 25A940ED25CB00A200689556 /* Products */, 92 | ); 93 | sourceTree = ""; 94 | }; 95 | 25A940ED25CB00A200689556 /* Products */ = { 96 | isa = PBXGroup; 97 | children = ( 98 | 25A940EC25CB00A200689556 /* SimpleImageViewer.app */, 99 | ); 100 | name = Products; 101 | sourceTree = ""; 102 | }; 103 | 25A940EE25CB00A200689556 /* SimpleImageViewer */ = { 104 | isa = PBXGroup; 105 | children = ( 106 | 25A940EF25CB00A200689556 /* AppDelegate.swift */, 107 | 2586136225DEA80A00941A46 /* AppCoordinator.swift */, 108 | 25AF258325CDAA55003CE640 /* Model */, 109 | 25AF258125CDAA45003CE640 /* Utils */, 110 | 25AF257F25CDAA2A003CE640 /* Views */, 111 | 25A940F325CB00A200689556 /* Assets.xcassets */, 112 | 25A940F825CB00A200689556 /* Main.storyboard */, 113 | 25A940FB25CB00A200689556 /* Info.plist */, 114 | 25A940FC25CB00A200689556 /* SimpleImageViewer.entitlements */, 115 | 25A940F525CB00A200689556 /* Preview Content */, 116 | ); 117 | path = SimpleImageViewer; 118 | sourceTree = ""; 119 | }; 120 | 25A940F525CB00A200689556 /* Preview Content */ = { 121 | isa = PBXGroup; 122 | children = ( 123 | 25A940F625CB00A200689556 /* Preview Assets.xcassets */, 124 | ); 125 | path = "Preview Content"; 126 | sourceTree = ""; 127 | }; 128 | 25AF257F25CDAA2A003CE640 /* Views */ = { 129 | isa = PBXGroup; 130 | children = ( 131 | 25A940F125CB00A200689556 /* ContentView.swift */, 132 | 25C72A4626D94627000266D7 /* DetailImageView.swift */, 133 | 258D1E5F27301B4900B4A85F /* DetailVideoView.swift */, 134 | 25D7A6572789E022009A2005 /* ExecuteClosure.swift */, 135 | 25AF256625CD7E64003CE640 /* FilesView.swift */, 136 | 25A9410325CB048700689556 /* ImageView.swift */, 137 | 2595AA1625E6A9CF007D50D7 /* ImageViewLazy.swift */, 138 | 25B3563525D17A9A00A451B6 /* MenuCommands.swift */, 139 | 25AF257A25CD8D71003CE640 /* SingleImageView.swift */, 140 | 25B3563B25D185D100A451B6 /* SingleEntryView.swift */, 141 | 258A1EFA26D3D9EF00B92BDE /* ThumbnailView.swift */, 142 | ); 143 | path = Views; 144 | sourceTree = ""; 145 | }; 146 | 25AF258125CDAA45003CE640 /* Utils */ = { 147 | isa = PBXGroup; 148 | children = ( 149 | 25AF257225CD81E6003CE640 /* DropUtils.swift */, 150 | 25CD27E025D81DE4009B0EB5 /* FavoritesManager.swift */, 151 | 25AF256F25CD801E003CE640 /* FilesystemManager.swift */, 152 | 25A9410725CB053C00689556 /* ImageLoader.swift */, 153 | 25B3563F25D1934800A451B6 /* ImageUtils.swift */, 154 | 25B3563225D177B900A451B6 /* MenuCommandsHandler.swift */, 155 | 258A1EFC26D3DFB900B92BDE /* ThumbnailLoader.swift */, 156 | ); 157 | path = Utils; 158 | sourceTree = ""; 159 | }; 160 | 25AF258325CDAA55003CE640 /* Model */ = { 161 | isa = PBXGroup; 162 | children = ( 163 | 25AF256925CD7EE1003CE640 /* FileEntry.swift */, 164 | 25AF257625CD87EB003CE640 /* FilesViewModel.swift */, 165 | 25C72A4426D94517000266D7 /* DetailImageViewModel.swift */, 166 | 258D1E612730223B00B4A85F /* DetailVideoViewModel.swift */, 167 | 25DF0AC726EBABB000188575 /* SingleEntryViewModel.swift */, 168 | 25E065C226F4F9F0005490BC /* SingleImageViewModel.swift */, 169 | ); 170 | path = Model; 171 | sourceTree = ""; 172 | }; 173 | /* End PBXGroup section */ 174 | 175 | /* Begin PBXNativeTarget section */ 176 | 25A940EB25CB00A200689556 /* SimpleImageViewer */ = { 177 | isa = PBXNativeTarget; 178 | buildConfigurationList = 25A940FF25CB00A200689556 /* Build configuration list for PBXNativeTarget "SimpleImageViewer" */; 179 | buildPhases = ( 180 | 25A940E825CB00A200689556 /* Sources */, 181 | 25A940E925CB00A200689556 /* Frameworks */, 182 | 25A940EA25CB00A200689556 /* Resources */, 183 | ); 184 | buildRules = ( 185 | ); 186 | dependencies = ( 187 | ); 188 | name = SimpleImageViewer; 189 | productName = SimpleImageViewer; 190 | productReference = 25A940EC25CB00A200689556 /* SimpleImageViewer.app */; 191 | productType = "com.apple.product-type.application"; 192 | }; 193 | /* End PBXNativeTarget section */ 194 | 195 | /* Begin PBXProject section */ 196 | 25A940E425CB00A200689556 /* Project object */ = { 197 | isa = PBXProject; 198 | attributes = { 199 | LastSwiftUpdateCheck = 1240; 200 | LastUpgradeCheck = 1240; 201 | TargetAttributes = { 202 | 25A940EB25CB00A200689556 = { 203 | CreatedOnToolsVersion = 12.4; 204 | }; 205 | }; 206 | }; 207 | buildConfigurationList = 25A940E725CB00A200689556 /* Build configuration list for PBXProject "SimpleImageViewer" */; 208 | compatibilityVersion = "Xcode 9.3"; 209 | developmentRegion = en; 210 | hasScannedForEncodings = 0; 211 | knownRegions = ( 212 | en, 213 | Base, 214 | ); 215 | mainGroup = 25A940E325CB00A200689556; 216 | productRefGroup = 25A940ED25CB00A200689556 /* Products */; 217 | projectDirPath = ""; 218 | projectRoot = ""; 219 | targets = ( 220 | 25A940EB25CB00A200689556 /* SimpleImageViewer */, 221 | ); 222 | }; 223 | /* End PBXProject section */ 224 | 225 | /* Begin PBXResourcesBuildPhase section */ 226 | 25A940EA25CB00A200689556 /* Resources */ = { 227 | isa = PBXResourcesBuildPhase; 228 | buildActionMask = 2147483647; 229 | files = ( 230 | 25A940FA25CB00A200689556 /* Main.storyboard in Resources */, 231 | 25A940F725CB00A200689556 /* Preview Assets.xcassets in Resources */, 232 | 25A940F425CB00A200689556 /* Assets.xcassets in Resources */, 233 | ); 234 | runOnlyForDeploymentPostprocessing = 0; 235 | }; 236 | /* End PBXResourcesBuildPhase section */ 237 | 238 | /* Begin PBXSourcesBuildPhase section */ 239 | 25A940E825CB00A200689556 /* Sources */ = { 240 | isa = PBXSourcesBuildPhase; 241 | buildActionMask = 2147483647; 242 | files = ( 243 | 25AF257B25CD8D71003CE640 /* SingleImageView.swift in Sources */, 244 | 25E065C326F4F9F0005490BC /* SingleImageViewModel.swift in Sources */, 245 | 25AF257325CD81E6003CE640 /* DropUtils.swift in Sources */, 246 | 258D1E622730223B00B4A85F /* DetailVideoViewModel.swift in Sources */, 247 | 25B3563C25D185D100A451B6 /* SingleEntryView.swift in Sources */, 248 | 258A1EFB26D3D9EF00B92BDE /* ThumbnailView.swift in Sources */, 249 | 2595AA1725E6A9CF007D50D7 /* ImageViewLazy.swift in Sources */, 250 | 25A9410425CB048700689556 /* ImageView.swift in Sources */, 251 | 25C72A4526D94517000266D7 /* DetailImageViewModel.swift in Sources */, 252 | 25AF256725CD7E64003CE640 /* FilesView.swift in Sources */, 253 | 25C72A4726D94627000266D7 /* DetailImageView.swift in Sources */, 254 | 25AF257725CD87EB003CE640 /* FilesViewModel.swift in Sources */, 255 | 25B3563325D177B900A451B6 /* MenuCommandsHandler.swift in Sources */, 256 | 25B3563625D17A9A00A451B6 /* MenuCommands.swift in Sources */, 257 | 258D1E6027301B4900B4A85F /* DetailVideoView.swift in Sources */, 258 | 25AF256A25CD7EE1003CE640 /* FileEntry.swift in Sources */, 259 | 25A940F225CB00A200689556 /* ContentView.swift in Sources */, 260 | 258A1EFD26D3DFB900B92BDE /* ThumbnailLoader.swift in Sources */, 261 | 2586136325DEA80A00941A46 /* AppCoordinator.swift in Sources */, 262 | 25A9410825CB053C00689556 /* ImageLoader.swift in Sources */, 263 | 25AF257025CD801E003CE640 /* FilesystemManager.swift in Sources */, 264 | 25CD27E125D81DE4009B0EB5 /* FavoritesManager.swift in Sources */, 265 | 25D7A6582789E022009A2005 /* ExecuteClosure.swift in Sources */, 266 | 25DF0AC826EBABB000188575 /* SingleEntryViewModel.swift in Sources */, 267 | 25A940F025CB00A200689556 /* AppDelegate.swift in Sources */, 268 | 25B3564025D1934800A451B6 /* ImageUtils.swift in Sources */, 269 | ); 270 | runOnlyForDeploymentPostprocessing = 0; 271 | }; 272 | /* End PBXSourcesBuildPhase section */ 273 | 274 | /* Begin PBXVariantGroup section */ 275 | 25A940F825CB00A200689556 /* Main.storyboard */ = { 276 | isa = PBXVariantGroup; 277 | children = ( 278 | 25A940F925CB00A200689556 /* Base */, 279 | ); 280 | name = Main.storyboard; 281 | sourceTree = ""; 282 | }; 283 | /* End PBXVariantGroup section */ 284 | 285 | /* Begin XCBuildConfiguration section */ 286 | 25A940FD25CB00A200689556 /* Debug */ = { 287 | isa = XCBuildConfiguration; 288 | buildSettings = { 289 | ALWAYS_SEARCH_USER_PATHS = NO; 290 | CLANG_ANALYZER_NONNULL = YES; 291 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 292 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 293 | CLANG_CXX_LIBRARY = "libc++"; 294 | CLANG_ENABLE_MODULES = YES; 295 | CLANG_ENABLE_OBJC_ARC = YES; 296 | CLANG_ENABLE_OBJC_WEAK = YES; 297 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 298 | CLANG_WARN_BOOL_CONVERSION = YES; 299 | CLANG_WARN_COMMA = YES; 300 | CLANG_WARN_CONSTANT_CONVERSION = YES; 301 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 302 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 303 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 304 | CLANG_WARN_EMPTY_BODY = YES; 305 | CLANG_WARN_ENUM_CONVERSION = YES; 306 | CLANG_WARN_INFINITE_RECURSION = YES; 307 | CLANG_WARN_INT_CONVERSION = YES; 308 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 309 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 310 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 311 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 312 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 313 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 314 | CLANG_WARN_STRICT_PROTOTYPES = YES; 315 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 316 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 317 | CLANG_WARN_UNREACHABLE_CODE = YES; 318 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 319 | COPY_PHASE_STRIP = NO; 320 | DEBUG_INFORMATION_FORMAT = dwarf; 321 | ENABLE_STRICT_OBJC_MSGSEND = YES; 322 | ENABLE_TESTABILITY = YES; 323 | GCC_C_LANGUAGE_STANDARD = gnu11; 324 | GCC_DYNAMIC_NO_PIC = NO; 325 | GCC_NO_COMMON_BLOCKS = YES; 326 | GCC_OPTIMIZATION_LEVEL = 0; 327 | GCC_PREPROCESSOR_DEFINITIONS = ( 328 | "DEBUG=1", 329 | "$(inherited)", 330 | ); 331 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 332 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 333 | GCC_WARN_UNDECLARED_SELECTOR = YES; 334 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 335 | GCC_WARN_UNUSED_FUNCTION = YES; 336 | GCC_WARN_UNUSED_VARIABLE = YES; 337 | MACOSX_DEPLOYMENT_TARGET = 10.15; 338 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 339 | MTL_FAST_MATH = YES; 340 | ONLY_ACTIVE_ARCH = YES; 341 | SDKROOT = macosx; 342 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 343 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 344 | }; 345 | name = Debug; 346 | }; 347 | 25A940FE25CB00A200689556 /* Release */ = { 348 | isa = XCBuildConfiguration; 349 | buildSettings = { 350 | ALWAYS_SEARCH_USER_PATHS = NO; 351 | CLANG_ANALYZER_NONNULL = YES; 352 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 353 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 354 | CLANG_CXX_LIBRARY = "libc++"; 355 | CLANG_ENABLE_MODULES = YES; 356 | CLANG_ENABLE_OBJC_ARC = YES; 357 | CLANG_ENABLE_OBJC_WEAK = YES; 358 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 359 | CLANG_WARN_BOOL_CONVERSION = YES; 360 | CLANG_WARN_COMMA = YES; 361 | CLANG_WARN_CONSTANT_CONVERSION = YES; 362 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 363 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 364 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 365 | CLANG_WARN_EMPTY_BODY = YES; 366 | CLANG_WARN_ENUM_CONVERSION = YES; 367 | CLANG_WARN_INFINITE_RECURSION = YES; 368 | CLANG_WARN_INT_CONVERSION = YES; 369 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 370 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 371 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 372 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 373 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 374 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 375 | CLANG_WARN_STRICT_PROTOTYPES = YES; 376 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 377 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 378 | CLANG_WARN_UNREACHABLE_CODE = YES; 379 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 380 | COPY_PHASE_STRIP = NO; 381 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 382 | ENABLE_NS_ASSERTIONS = NO; 383 | ENABLE_STRICT_OBJC_MSGSEND = YES; 384 | GCC_C_LANGUAGE_STANDARD = gnu11; 385 | GCC_NO_COMMON_BLOCKS = YES; 386 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 387 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 388 | GCC_WARN_UNDECLARED_SELECTOR = YES; 389 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 390 | GCC_WARN_UNUSED_FUNCTION = YES; 391 | GCC_WARN_UNUSED_VARIABLE = YES; 392 | MACOSX_DEPLOYMENT_TARGET = 10.15; 393 | MTL_ENABLE_DEBUG_INFO = NO; 394 | MTL_FAST_MATH = YES; 395 | SDKROOT = macosx; 396 | SWIFT_COMPILATION_MODE = wholemodule; 397 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 398 | }; 399 | name = Release; 400 | }; 401 | 25A9410025CB00A200689556 /* Debug */ = { 402 | isa = XCBuildConfiguration; 403 | buildSettings = { 404 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 405 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 406 | CODE_SIGN_ENTITLEMENTS = SimpleImageViewer/SimpleImageViewer.entitlements; 407 | CODE_SIGN_STYLE = Automatic; 408 | COMBINE_HIDPI_IMAGES = YES; 409 | DEVELOPMENT_ASSET_PATHS = "\"SimpleImageViewer/Preview Content\""; 410 | DEVELOPMENT_TEAM = EMNHGG4C7C; 411 | ENABLE_HARDENED_RUNTIME = YES; 412 | ENABLE_PREVIEWS = YES; 413 | INFOPLIST_FILE = SimpleImageViewer/Info.plist; 414 | LD_RUNPATH_SEARCH_PATHS = ( 415 | "$(inherited)", 416 | "@executable_path/../Frameworks", 417 | ); 418 | MACOSX_DEPLOYMENT_TARGET = 11.0; 419 | PRODUCT_BUNDLE_IDENTIFIER = com.gfrigerio.SimpleImageViewer; 420 | PRODUCT_NAME = "$(TARGET_NAME)"; 421 | SWIFT_VERSION = 5.0; 422 | }; 423 | name = Debug; 424 | }; 425 | 25A9410125CB00A200689556 /* Release */ = { 426 | isa = XCBuildConfiguration; 427 | buildSettings = { 428 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 429 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 430 | CODE_SIGN_ENTITLEMENTS = SimpleImageViewer/SimpleImageViewer.entitlements; 431 | CODE_SIGN_STYLE = Automatic; 432 | COMBINE_HIDPI_IMAGES = YES; 433 | DEVELOPMENT_ASSET_PATHS = "\"SimpleImageViewer/Preview Content\""; 434 | DEVELOPMENT_TEAM = EMNHGG4C7C; 435 | ENABLE_HARDENED_RUNTIME = YES; 436 | ENABLE_PREVIEWS = YES; 437 | INFOPLIST_FILE = SimpleImageViewer/Info.plist; 438 | LD_RUNPATH_SEARCH_PATHS = ( 439 | "$(inherited)", 440 | "@executable_path/../Frameworks", 441 | ); 442 | MACOSX_DEPLOYMENT_TARGET = 11.0; 443 | PRODUCT_BUNDLE_IDENTIFIER = com.gfrigerio.SimpleImageViewer; 444 | PRODUCT_NAME = "$(TARGET_NAME)"; 445 | SWIFT_VERSION = 5.0; 446 | }; 447 | name = Release; 448 | }; 449 | /* End XCBuildConfiguration section */ 450 | 451 | /* Begin XCConfigurationList section */ 452 | 25A940E725CB00A200689556 /* Build configuration list for PBXProject "SimpleImageViewer" */ = { 453 | isa = XCConfigurationList; 454 | buildConfigurations = ( 455 | 25A940FD25CB00A200689556 /* Debug */, 456 | 25A940FE25CB00A200689556 /* Release */, 457 | ); 458 | defaultConfigurationIsVisible = 0; 459 | defaultConfigurationName = Release; 460 | }; 461 | 25A940FF25CB00A200689556 /* Build configuration list for PBXNativeTarget "SimpleImageViewer" */ = { 462 | isa = XCConfigurationList; 463 | buildConfigurations = ( 464 | 25A9410025CB00A200689556 /* Debug */, 465 | 25A9410125CB00A200689556 /* Release */, 466 | ); 467 | defaultConfigurationIsVisible = 0; 468 | defaultConfigurationName = Release; 469 | }; 470 | /* End XCConfigurationList section */ 471 | }; 472 | rootObject = 25A940E425CB00A200689556 /* Project object */; 473 | } 474 | -------------------------------------------------------------------------------- /SimpleImageViewer.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SimpleImageViewer.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SimpleImageViewer.xcodeproj/project.xcworkspace/xcuserdata/gualtierofrigerio.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gualtierofrigerio/SimpleImageViewer/702c3543979df8a3bc9170e56f9d021b9b719c1e/SimpleImageViewer.xcodeproj/project.xcworkspace/xcuserdata/gualtierofrigerio.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /SimpleImageViewer.xcodeproj/xcuserdata/gualtierofrigerio.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /SimpleImageViewer.xcodeproj/xcuserdata/gualtierofrigerio.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | SimpleImageViewer.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /SimpleImageViewer/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 18/02/21. 6 | // 7 | 8 | import Foundation 9 | 10 | class AppCoordinator { 11 | private (set) var detailImageViewModel: DetailImageViewModel 12 | private (set) var detailVideoViewModel: DetailVideoViewModel 13 | private (set) var favoritesManager: FavoritesManager = FavoritesManager() 14 | private (set) var filesViewModel: FilesViewModel 15 | 16 | var currentImageURL: URL? 17 | var currentVideoURL: URL? 18 | 19 | init() { 20 | filesViewModel = FilesViewModel(favoritesManager: favoritesManager) 21 | detailImageViewModel = DetailImageViewModel() 22 | detailVideoViewModel = DetailVideoViewModel() 23 | } 24 | 25 | func setDirectory(_ url:URL) { 26 | filesViewModel.setDirectory(url) 27 | } 28 | 29 | func showFavorites() { 30 | let allFavorites = favoritesManager.favorites.compactMap { 31 | FileEntry.createFromFileString($0) 32 | } 33 | filesViewModel.updateEntries(allFavorites) 34 | } 35 | 36 | func showImage(atURL url: URL) { 37 | detailImageViewModel.showImage(atURL: url) 38 | } 39 | 40 | func showVideo(atURL url: URL) { 41 | detailVideoViewModel.showVideo(atURL: url) 42 | } 43 | 44 | func toogleFavorite(forFileEntry entry:FileEntry) { 45 | favoritesManager.toggle(fileEntry: entry) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SimpleImageViewer/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 03/02/2021. 6 | // 7 | 8 | import Cocoa 9 | import SwiftUI 10 | 11 | /* 12 | If you can target macOS 11 you can use Scene 13 | */ 14 | 15 | 16 | @main 17 | struct SimpleImageViewer: App { 18 | var coordinator:AppCoordinator 19 | var menuCommandsHandler:MenuCommandsHandler 20 | 21 | init() { 22 | coordinator = AppCoordinator() 23 | menuCommandsHandler = MenuCommandsHandler(coordinator: coordinator) 24 | } 25 | 26 | var body: some Scene { 27 | AppScene(coordinator: coordinator, menuCommandsHandler: menuCommandsHandler) 28 | } 29 | } 30 | 31 | struct AppScene:Scene { 32 | var coordinator:AppCoordinator 33 | var menuCommandsHandler:MenuCommandsHandler 34 | 35 | var body: some Scene { 36 | WindowGroup { 37 | ContentView(coordinator: coordinator) 38 | }.commands { 39 | MenuCommands(commandsHandler: menuCommandsHandler) 40 | } 41 | } 42 | } 43 | 44 | 45 | // this was created by Xcode when I started the macOS project 46 | /* 47 | @main 48 | class AppDelegate: NSObject, NSApplicationDelegate { 49 | 50 | var window: NSWindow! 51 | 52 | func applicationDidFinishLaunching(_ aNotification: Notification) { 53 | // Create the SwiftUI view that provides the window contents. 54 | let contentView = ContentView() 55 | 56 | // Create the window and set the content view. 57 | window = NSWindow( 58 | contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), 59 | styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], 60 | backing: .buffered, defer: false) 61 | window.isReleasedWhenClosed = false 62 | window.center() 63 | window.setFrameAutosaveName("Main Window") 64 | window.contentView = NSHostingView(rootView: contentView) 65 | window.makeKeyAndOrderFront(nil) 66 | } 67 | 68 | func applicationWillTerminate(_ aNotification: Notification) { 69 | // Insert code here to tear down your application 70 | } 71 | 72 | 73 | } 74 | 75 | */ 76 | -------------------------------------------------------------------------------- /SimpleImageViewer/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SimpleImageViewer/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /SimpleImageViewer/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SimpleImageViewer/Base.lproj/Main.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 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /SimpleImageViewer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSPrincipalClass 26 | NSApplication 27 | 28 | 29 | -------------------------------------------------------------------------------- /SimpleImageViewer/Model/DetailImageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleImageViewModel.swift 3 | // SingleImageViewModel 4 | // 5 | // Created by Gualtiero Frigerio on 27/08/21. 6 | // 7 | 8 | import Foundation 9 | 10 | import Foundation 11 | import Combine 12 | 13 | /// View model for FilesView 14 | /// calling showImage sets a @Published var that forces the update of the view 15 | class DetailImageViewModel: ObservableObject { 16 | @Published var imageURL: URL? 17 | @Published var currentZoomDisplay: String = "100 %" 18 | 19 | @Published var stepperValue: Int = 1 { 20 | didSet { 21 | if let singleImageViewModel = singleImageViewModel { 22 | singleImageViewModel.scale = 1.0 + CGFloat(stepperValue) / 10 23 | } 24 | } 25 | } 26 | 27 | var singleImageViewModel: SingleImageViewModel? 28 | 29 | /// Loads an image forcing the view to show it 30 | /// - Parameter url: the image url 31 | func showImage(atURL url: URL) { 32 | imageURL = url 33 | let imageVM = SingleImageViewModel(imageURL: url) 34 | singleImageViewModel = imageVM 35 | cancellable = imageVM.$scale.sink { value in 36 | self.updateZoom(withScale: value) 37 | } 38 | } 39 | 40 | // MARK: - Private 41 | 42 | private var cancellable: AnyCancellable? 43 | 44 | private func updateZoom(withScale scale: CGFloat) { 45 | currentZoomDisplay = String(format: "%.2f", scale * 100) + " %" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SimpleImageViewer/Model/DetailVideoViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailVideoViewModel.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 01/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | class DetailVideoViewModel: ObservableObject { 11 | @Published var videoURL: URL? 12 | 13 | func showVideo(atURL url: URL) { 14 | videoURL = url 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /SimpleImageViewer/Model/FileEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileEntry.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 05/02/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum FileEntryType { 11 | case directory 12 | case image 13 | case video 14 | } 15 | 16 | /// Describes a filesystem entry 17 | struct FileEntry { 18 | var type: FileEntryType 19 | var isFavorite: Bool = false // true if is part of the user favorites 20 | var fileURL: URL // full path url string 21 | var name: String // file/directory name 22 | var modificationDate: Date // file modification date 23 | } 24 | 25 | extension FileEntry { 26 | /// Create a FileEntry from a File at a given path 27 | /// - Parameter path: String describing the path of the file to read 28 | /// - Returns: An optional FileEntry constructed from the given file 29 | static func createFromFileString(_ path:String) -> Self? { 30 | let absolutePath = path.replacingOccurrences(of: "file://", with: "") 31 | let fileManager = FileManager.default 32 | var isDir:ObjCBool = false 33 | let exists = fileManager.fileExists(atPath: absolutePath, isDirectory: &isDir) 34 | if exists == false { 35 | return nil 36 | } 37 | let url = URL(fileURLWithPath: absolutePath) 38 | let name = url.lastPathComponent 39 | var isVideo = false 40 | if url.pathExtension == "mov" { 41 | isVideo = true 42 | } 43 | var entryType: FileEntryType = .image 44 | if isDir.boolValue == true { 45 | entryType = .directory 46 | } 47 | else if isVideo { 48 | entryType = .video 49 | } 50 | var modificationDate: Date? 51 | if let attributes = try? fileManager.attributesOfItem(atPath: absolutePath) as [FileAttributeKey: Any], 52 | let date = attributes[FileAttributeKey.modificationDate] as? Date { 53 | modificationDate = date 54 | } 55 | 56 | return FileEntry(type: entryType, 57 | fileURL: url, 58 | name: name, 59 | modificationDate: modificationDate ?? Date()) 60 | } 61 | } 62 | 63 | extension FileEntry:Identifiable { 64 | var id: String { 65 | name 66 | } 67 | } 68 | 69 | extension FileEntry:Equatable { 70 | static func ==(lhs:FileEntry, rhs:FileEntry) -> Bool { 71 | lhs.fileURL.absoluteString == rhs.fileURL.absoluteString 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /SimpleImageViewer/Model/FilesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilesViewModel.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 05/02/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import SwiftUI 11 | 12 | enum FilesOrder { 13 | case name 14 | case modificationDate 15 | } 16 | 17 | /// View model for FilesView 18 | class FilesViewModel: ObservableObject { 19 | @Published var entries: [FileEntry] = [] 20 | @Published var orderBy: FilesOrder = .name { 21 | didSet { 22 | sortEntries() 23 | } 24 | } 25 | @Published var orderAscending = true { 26 | didSet { 27 | sortEntries() 28 | } 29 | } 30 | var scrollViewProxy: ScrollViewProxy? 31 | var scrollViewTopId: String = "_scroll_view_top_id_" 32 | 33 | var supportedExtensions = ["jpg", "jpeg", "png", "heic", "mov"] 34 | 35 | init(favoritesManager: FavoritesManager) { 36 | self.favoritesManager = favoritesManager 37 | favoritesCancellable = favoritesManager.$favorites.sink { _ in 38 | self.updateEntries(self.entries) 39 | } 40 | } 41 | 42 | /// Sets the current directory and updates the entries variable 43 | /// so the view objserving entries can be updated 44 | /// - Parameter dir: URL of the directory 45 | func setDirectory(_ dir: URL) { 46 | guard let fileEntries = FilesystemManager.getFileEntries(forDirectory: dir) else { return } 47 | var entriesToShow = fileEntries 48 | .filter(filterClosure) 49 | .sorted(by: sortClosure) 50 | let parentDir = getParentDir(ofDir: dir) 51 | entriesToShow.insert(parentDir, at: 0) 52 | updateEntries(entriesToShow) 53 | } 54 | 55 | /// Updates the entries variable in the main thread 56 | /// - Parameter entries: array of FileEntry that will be set to entries 57 | func updateEntries(_ entries:[FileEntry]) { 58 | DispatchQueue.main.async { 59 | self.entries = entries.map(self.favoritesClosure) 60 | self.scrollViewProxy?.scrollTo(self.scrollViewTopId, anchor: .top) 61 | } 62 | } 63 | 64 | // MARK: - Private 65 | 66 | private var favoritesCancellable:AnyCancellable? 67 | private var favoritesManager:FavoritesManager 68 | 69 | private func getParentDir(ofDir dir:URL) -> FileEntry { 70 | let parentURL = dir.deletingLastPathComponent() 71 | return FileEntry(type: .directory, 72 | fileURL: parentURL, 73 | name: "..", 74 | modificationDate: Date()) 75 | } 76 | 77 | private func favoritesClosure(entry:FileEntry) -> FileEntry { 78 | var newEntry = entry 79 | newEntry.isFavorite = favoritesManager.isFavorite(entry) 80 | if newEntry.isFavorite { 81 | print("new entry is favorite") 82 | } 83 | return newEntry 84 | } 85 | 86 | private func filterClosure(entry:FileEntry) -> Bool { 87 | var keep = false 88 | if entry.type == .directory { 89 | keep = true 90 | } 91 | let fileExtention = entry.fileURL.pathExtension.lowercased() 92 | if supportedExtensions.contains(fileExtention) { 93 | keep = true 94 | } 95 | return keep 96 | } 97 | 98 | private func sortClosure(lhs:FileEntry, rhs:FileEntry) -> Bool { 99 | // internal function to sort two entries 100 | func sortEntries(lhs:FileEntry, rhs:FileEntry) -> Bool { 101 | if orderBy == .name { 102 | if orderAscending { 103 | return lhs.name.lowercased() < rhs.name.lowercased() 104 | } 105 | else { 106 | return lhs.name.lowercased() > rhs.name.lowercased() 107 | } 108 | } 109 | else { 110 | if orderAscending { 111 | return lhs.modificationDate < rhs.modificationDate 112 | } 113 | else { 114 | return lhs.modificationDate > rhs.modificationDate 115 | } 116 | } 117 | } 118 | 119 | if (lhs.type == rhs.type) || 120 | (lhs.type != .directory && rhs.type != .directory) { 121 | return sortEntries(lhs: lhs, rhs: rhs) 122 | } 123 | else { 124 | return lhs.type == .directory 125 | } 126 | } 127 | 128 | private func sortEntries() { 129 | let sortedEntries = entries.sorted(by: sortClosure) 130 | updateEntries(sortedEntries) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /SimpleImageViewer/Model/SingleEntryViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleEntryViewModel.swift 3 | // SingleEntryViewModel 4 | // 5 | // Created by Gualtiero Frigerio on 12/09/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | class SingleEntryViewModel: ObservableObject { 12 | var buttonAction: () -> Void 13 | var entry: FileEntry 14 | 15 | init(entry: FileEntry, coordinator: AppCoordinator) { 16 | self.entry = entry 17 | self.coordinator = coordinator 18 | self.buttonAction = {} 19 | if entry.type != .directory { 20 | buttonAction = toggleFavorite 21 | } 22 | } 23 | 24 | private var coordinator: AppCoordinator 25 | 26 | private func toggleFavorite() { 27 | coordinator.toogleFavorite(forFileEntry: entry) 28 | entry.isFavorite = coordinator.favoritesManager.isFavorite(entry) 29 | objectWillChange.send() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SimpleImageViewer/Model/SingleImageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleImageViewModel.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 02/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | class SingleImageViewModel: ObservableObject { 11 | internal init(imageURL: URL) { 12 | self.imageURL = imageURL 13 | } 14 | 15 | let imageURL: URL 16 | 17 | @Published var magnificationValue: CGFloat = 1.0 { 18 | didSet { 19 | updateMagnification(magnificationValue) 20 | } 21 | } 22 | 23 | @Published var scale: CGFloat = 1.0 24 | 25 | func endedMagnification() { 26 | withAnimation { 27 | scale = 1.0 28 | } 29 | } 30 | 31 | // MARK: - Private 32 | 33 | private func updateMagnification(_ value: CGFloat) { 34 | let adjustedValue = scale * value 35 | if adjustedValue > 0.5 && adjustedValue < 3 { 36 | scale = value 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /SimpleImageViewer/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SimpleImageViewer/SimpleImageViewer.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /SimpleImageViewer/Utils/DropUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DropUtils.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 05/02/21. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// Utility class to implement the drag and drop functionallity 12 | class DropUtils { 13 | /// Extract the URL of a file from the DropInfo object 14 | /// - Parameters: 15 | /// - info: The DropInfo object 16 | /// - completion: completion handler with an optional URL 17 | class func urlFromDropInfo(_ info:DropInfo, completion: @escaping (URL?) -> Void) { 18 | guard let itemProvider = info.itemProviders(for: [(kUTTypeFileURL as String)]).first else { 19 | completion(nil) 20 | return 21 | } 22 | 23 | itemProvider.loadItem(forTypeIdentifier: (kUTTypeFileURL as String), options: nil) {item, error in 24 | guard let data = item as? Data, 25 | let url = URL(dataRepresentation: data, relativeTo: nil) else { 26 | completion(nil) 27 | return 28 | } 29 | completion(url) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /SimpleImageViewer/Utils/FavoritesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesManager.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 13/02/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | /// Utility class to manage favorites 12 | class FavoritesManager { 13 | /// This array of String can be observed to show a list of favorites 14 | @Published var favorites:[String] = [] 15 | 16 | init() { 17 | load() 18 | } 19 | 20 | /// Add a FileEntry to the favorites 21 | /// - Parameter fileEntry: The FileEntry to add 22 | func add(fileEntry:FileEntry) { 23 | favorites.append(fileEntry.fileURL.absoluteString) 24 | save() 25 | } 26 | 27 | /// Check if a FileEntry is favorite 28 | /// - Parameter entry: The FileEntry to check 29 | /// - Returns: true if the entry is favorite 30 | func isFavorite(_ entry:FileEntry) -> Bool { 31 | favorites.contains(entry.fileURL.absoluteString) 32 | } 33 | 34 | /// Remove a FileEntry from favorites 35 | /// - Parameter fileEntry: The FileEntry to remove 36 | func remove(fileEntry:FileEntry) { 37 | favorites.removeAll(where: {$0 == fileEntry.fileURL.absoluteString}) 38 | save() 39 | } 40 | 41 | /// Toggle the favorite status for a FileEntry 42 | /// - Parameter fileEntry: The FileEntry to toggle 43 | func toggle(fileEntry:FileEntry) { 44 | if favorites.contains(fileEntry.fileURL.absoluteString) { 45 | remove(fileEntry: fileEntry) 46 | } 47 | else { 48 | add(fileEntry: fileEntry) 49 | } 50 | } 51 | 52 | // MARK: - Private 53 | 54 | 55 | private func load() { 56 | let defaults = UserDefaults.standard 57 | if let fileEntries = defaults.object(forKey: "favorites") as? [String] { 58 | favorites = fileEntries 59 | } 60 | } 61 | 62 | private func save() { 63 | let defaults = UserDefaults.standard 64 | defaults.setValue(favorites, forKey: "favorites") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /SimpleImageViewer/Utils/FilesystemManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilesystemManager.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 05/02/21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Helper class to manage files 11 | class FilesystemManager { 12 | /// Reads files into a directory and returns an array of FileEntry 13 | /// - Parameter dir: URL of the directory to scan 14 | /// - Returns: An optional array of FileEntry 15 | static func getFileEntries(forDirectory dir:URL) -> [FileEntry]? { 16 | let fileManager = FileManager() 17 | guard let items = try? fileManager.contentsOfDirectory(at: dir, 18 | includingPropertiesForKeys: nil, 19 | options: .skipsSubdirectoryDescendants) else { 20 | return nil 21 | } 22 | var entries:[FileEntry] = [] 23 | for item in items { 24 | let name = item.lastPathComponent 25 | let isDir = item.hasDirectoryPath 26 | let isVideo = item.pathExtension.lowercased() == "mov" 27 | var type = FileEntryType.image 28 | if isDir { 29 | type = .directory 30 | } 31 | else if isVideo { 32 | type = .video 33 | } 34 | 35 | var modificationDate: Date? 36 | if let attributes = try? fileManager.attributesOfItem(atPath: item.path) as [FileAttributeKey: Any], 37 | let date = attributes[FileAttributeKey.modificationDate] as? Date { 38 | modificationDate = date 39 | } 40 | let entry = FileEntry(type: type, 41 | fileURL: item, 42 | name: name, 43 | modificationDate: modificationDate ?? Date()) 44 | entries.append(entry) 45 | } 46 | return entries 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /SimpleImageViewer/Utils/ImageLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageLoader.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 03/02/2021. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | /// Observable object responsible to load an image to be used by SwiftUI views 12 | class ImageLoader: ObservableObject { 13 | var didChange = PassthroughSubject() 14 | var data = Data() { 15 | didSet { 16 | didChange.send(data) 17 | } 18 | } 19 | 20 | /// Load an image at the given URL 21 | /// - Parameter url: The image URL 22 | func load(url: URL) { 23 | loadImage(fromURL: url) 24 | } 25 | 26 | /// Load an image from a given string 27 | /// - Parameter urlString: The string representing the image URL 28 | func load(urlString:String) { 29 | guard let url = URL(string: urlString) else { return } 30 | loadImage(fromURL: url) 31 | } 32 | 33 | private func loadImage(fromURL url:URL) { 34 | let task = URLSession.shared.dataTask(with: url) { data, response, error in 35 | guard let data = data else { return } 36 | DispatchQueue.main.async { 37 | self.data = data 38 | } 39 | } 40 | task.resume() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SimpleImageViewer/Utils/ImageUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageUtils.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 09/02/21. 6 | // 7 | 8 | import Foundation 9 | import CoreImage 10 | 11 | /// Extention to add utility function to CGImage 12 | extension CGImage { 13 | 14 | /// Create a CGImage from a CIImage 15 | /// - Parameter ciImage: the CIImage from which the CGImage is created 16 | /// - Returns: An optional CGImage if the conversion is possible 17 | class func createFrom(ciImage:CIImage) -> CGImage? { 18 | let context = CIContext(options: nil) 19 | if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) { 20 | return cgImage 21 | } 22 | return nil 23 | } 24 | 25 | /// Resize the current CGImage to the given size 26 | /// - Parameter size: size of the new image 27 | /// - Returns: A new CGImage resized from the current one 28 | func resize(size:CGSize) -> CGImage { 29 | let context = CGContext(data: nil, 30 | width: Int(size.width), 31 | height: Int(size.height), 32 | bitsPerComponent: self.bitsPerComponent, 33 | bytesPerRow: self.bytesPerRow, 34 | space: self.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!, 35 | bitmapInfo: self.bitmapInfo.rawValue) 36 | context?.interpolationQuality = .high 37 | context?.draw(self, in: CGRect(origin: .zero, size: size)) 38 | return context?.makeImage() ?? self 39 | } 40 | 41 | /// Resize the current CGImage to the maximum size specified by the parameter. 42 | /// The aspect ratio is preserved 43 | /// - Parameter maxSize: maximum width/height of the new image 44 | /// - Returns: A new CGImage resised from the current one 45 | func resize(maxSize:Int) -> CGImage { 46 | var newSize = CGSize.zero 47 | let ratio = CGFloat(width / height) 48 | if (width > height) { 49 | newSize.width = CGFloat(maxSize) 50 | newSize.height = CGFloat(maxSize) / ratio 51 | } 52 | else { 53 | newSize.height = CGFloat(maxSize) 54 | newSize.width = CGFloat(maxSize) * ratio 55 | } 56 | return resize(size: newSize) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /SimpleImageViewer/Utils/MenuCommandsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuCommands.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 09/02/21. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | 11 | /// Handles the execution of commands from the macOS menu 12 | class MenuCommandsHandler { 13 | /// Initializer 14 | /// Requires the AppCoordinator to execute commands 15 | /// - Parameter coordinator: The AppCoordinator 16 | init(coordinator: AppCoordinator) { 17 | self.coordinator = coordinator 18 | } 19 | 20 | /// Executed when the open menu voice is clicked 21 | func openCommand() { 22 | let dialog = NSOpenPanel(); 23 | 24 | dialog.title = "Choose a directory" 25 | dialog.showsResizeIndicator = true 26 | dialog.showsHiddenFiles = false 27 | dialog.allowsMultipleSelection = false 28 | dialog.canChooseDirectories = true 29 | dialog.canChooseFiles = false 30 | 31 | if (dialog.runModal() == NSApplication.ModalResponse.OK) { 32 | if let url = dialog.url { 33 | coordinator.setDirectory(url) 34 | } 35 | } else { 36 | print("user cancelled") 37 | return 38 | } 39 | } 40 | 41 | /// Executed when the favorites menu voice is clicked 42 | func showFavoritesCommand() { 43 | coordinator.showFavorites() 44 | } 45 | 46 | // MARK: - Private 47 | 48 | private var coordinator:AppCoordinator 49 | } 50 | -------------------------------------------------------------------------------- /SimpleImageViewer/Utils/ThumbnailLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThumbnailLoader.swift 3 | // ThumbnailLoader 4 | // 5 | // Created by Gualtiero Frigerio on 23/08/21. 6 | // 7 | 8 | import AppKit 9 | import QuickLookThumbnailing 10 | 11 | /// Wrapper for QLThumbnailGenerator 12 | /// the class is ObservableObject and has a @Published var so 13 | /// a view can observe it and load a thumbnail as soon as it is ready 14 | class ThumbnailLoader: ObservableObject { 15 | @Published var image = NSImage() 16 | 17 | /// Tries to load a thumbnail for a given URL 18 | /// - Parameters: 19 | /// - url: URL of the image 20 | /// - maxSize: maximum size (width/height) aspect ratio preserved 21 | func loadThumbnail(url: URL, maxSize: Int) { 22 | let size = CGSize(width: maxSize, height: maxSize) 23 | let scale = 1.0 24 | let request = QLThumbnailGenerator.Request(fileAt: url, 25 | size: size, 26 | scale: scale, 27 | representationTypes: .all) 28 | 29 | let generator = QLThumbnailGenerator.shared 30 | generator.generateRepresentations(for: request) { (thumbnail, type, error) in 31 | guard let nsImage = thumbnail?.nsImage else { 32 | if let error = error { 33 | print("error while generating thumbnail \(error.localizedDescription)") 34 | } 35 | return 36 | } 37 | DispatchQueue.main.async { 38 | self.image = nsImage 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 03/02/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var coordinator:AppCoordinator 12 | 13 | var body: some View { 14 | NavigationView { 15 | VStack { 16 | FilesView(coordinator: coordinator) 17 | } 18 | } 19 | .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) 20 | } 21 | } 22 | 23 | struct ContentView_Previews: PreviewProvider { 24 | static var previews: some View { 25 | ContentView(coordinator: AppCoordinator()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/DetailImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailImageView.swift 3 | // DetailImageView 4 | // 5 | // Created by Gualtiero Frigerio on 27/08/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DetailImageView: View { 11 | @ObservedObject var viewModel: DetailImageViewModel 12 | 13 | var body: some View { 14 | VStack { 15 | if let singleImageViewModel = viewModel.singleImageViewModel { 16 | VStack { 17 | GeometryReader { proxy in 18 | ScrollView([.horizontal, .vertical]) { 19 | SingleImageView(viewModel: singleImageViewModel, 20 | containerSize: proxy.size) 21 | } 22 | } 23 | HStack { 24 | Text(viewModel.imageURL?.absoluteString ?? "") 25 | Stepper("Zoom level \(viewModel.currentZoomDisplay)", 26 | value: $viewModel.stepperValue, 27 | in: 0...9) 28 | } 29 | } 30 | } 31 | else { 32 | Text("Select an image from the list") 33 | } 34 | } 35 | } 36 | } 37 | 38 | struct DetailImageView_Previews: PreviewProvider { 39 | static var previews: some View { 40 | DetailImageView(viewModel: DetailImageViewModel()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/DetailVideoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailVideoView.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 01/11/21. 6 | // 7 | 8 | import AVKit 9 | import SwiftUI 10 | 11 | struct DetailVideoView: View { 12 | @ObservedObject var viewModel: DetailVideoViewModel 13 | 14 | var body: some View { 15 | if let url = viewModel.videoURL { 16 | VideoPlayer(player: playerForURL(url)) 17 | } 18 | else { 19 | Text("Select a video on the left") 20 | } 21 | } 22 | 23 | private func playerForURL(_ url: URL) -> AVPlayer { 24 | let player = AVPlayer(url: url) 25 | player.play() 26 | return player 27 | } 28 | } 29 | 30 | struct DetailVideoView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | DetailVideoView(viewModel: DetailVideoViewModel()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/ExecuteClosure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExecuteClosure.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 08/01/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ExecuteClosure: View { 11 | init( _ closure: () -> ()) { 12 | closure() 13 | } 14 | 15 | var body: some View { 16 | EmptyView() 17 | } 18 | } 19 | 20 | struct ExecuteClosure_Previews: PreviewProvider { 21 | static var previews: some View { 22 | ExecuteClosure({}) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/FilesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilesView.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 05/02/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FilesView: View { 11 | @ObservedObject var viewModel: FilesViewModel 12 | @State var alwaysActive = true 13 | @State var imageActive = false 14 | @State var videoActive = false 15 | 16 | init(coordinator:AppCoordinator) { 17 | self.coordinator = coordinator 18 | self.viewModel = coordinator.filesViewModel 19 | } 20 | 21 | // MARK: - View 22 | 23 | var body: some View { 24 | NavigationLink(destination: DetailImageView(viewModel: coordinator.detailImageViewModel), 25 | isActive: $imageActive){}.hidden() 26 | NavigationLink(destination: DetailVideoView(viewModel: coordinator.detailVideoViewModel), 27 | isActive: $videoActive){}.hidden() 28 | if noEntries { 29 | Text("Drag a folder here") 30 | } 31 | else { 32 | sortingView 33 | } 34 | ScrollViewReader { proxy in 35 | ExecuteClosure { 36 | viewModel.scrollViewProxy = proxy 37 | } 38 | ScrollView { 39 | EmptyView().id(viewModel.scrollViewTopId) 40 | LazyVStack(alignment: .leading) { 41 | ForEach(viewModel.entries) { entry in 42 | view(forEntry: entry) 43 | } 44 | } 45 | } 46 | } 47 | .onDrop(of: ["public.file-url"], delegate: self) 48 | } 49 | 50 | private var sortingView: some View { 51 | HStack { 52 | Picker(selection: $viewModel.orderBy, label: Text("Order by")) { 53 | Text("File name").tag(FilesOrder.name) 54 | Text("Modified date").tag(FilesOrder.modificationDate) 55 | }.pickerStyle(.segmented) 56 | Button(action:{ 57 | viewModel.orderAscending.toggle() 58 | } 59 | ) { 60 | if viewModel.orderAscending { 61 | Image(systemName: "arrow.up") 62 | } 63 | else { 64 | Image(systemName: "arrow.down") 65 | } 66 | }.buttonStyle(PlainButtonStyle()) 67 | } 68 | } 69 | 70 | @ViewBuilder private func view(forEntry entry: FileEntry) -> some View { 71 | switch entry.type { 72 | case .directory: 73 | Button(action:{ 74 | selectDirectory(entry:entry)}) { 75 | SingleEntryView(viewModel: viewModel(forEntry: entry)) 76 | }.buttonStyle(PlainButtonStyle()) 77 | case .image: 78 | Button(action: { 79 | coordinator.showImage(atURL: entry.fileURL) 80 | imageActive = true 81 | }) { 82 | SingleEntryView(viewModel: viewModel(forEntry: entry)) 83 | .frame(width:nil, height:250) 84 | }.buttonStyle(PlainButtonStyle()) 85 | case .video: 86 | Button(action: { 87 | coordinator.showVideo(atURL: entry.fileURL) 88 | videoActive = true 89 | }) { 90 | SingleEntryView(viewModel: viewModel(forEntry: entry)) 91 | .frame(width:nil, height:250) 92 | }.buttonStyle(PlainButtonStyle()) 93 | } 94 | } 95 | 96 | // MARK: - Private 97 | 98 | private var coordinator: AppCoordinator 99 | private var emptyAction: () -> Void = {} // used on dir entries as you don't need an action 100 | 101 | private var noEntries: Bool { 102 | viewModel.entries.count == 0 103 | } 104 | 105 | private func selectDirectory(entry: FileEntry) { 106 | coordinator.setDirectory(entry.fileURL) 107 | } 108 | 109 | private func toggleFavorite(_ entry: FileEntry) { 110 | coordinator.toogleFavorite(forFileEntry: entry) 111 | } 112 | 113 | private func viewModel(forEntry entry: FileEntry) -> SingleEntryViewModel { 114 | SingleEntryViewModel(entry: entry, coordinator: coordinator) 115 | } 116 | } 117 | 118 | extension FilesView:DropDelegate { 119 | func performDrop(info: DropInfo) -> Bool { 120 | DropUtils.urlFromDropInfo(info) { url in 121 | if let url = url { 122 | viewModel.setDirectory(url) 123 | } 124 | } 125 | return true 126 | } 127 | } 128 | 129 | struct FileView_Previews: PreviewProvider { 130 | static var previews: some View { 131 | FilesView(coordinator:AppCoordinator()) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/ImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageView.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 03/02/2021. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | struct ImageView: View { 12 | @ObservedObject var imageLoader = ImageLoader() 13 | @State var image:NSImage = NSImage() 14 | 15 | init(withURL url:URL) { 16 | imageLoader.load(url:url) 17 | } 18 | 19 | init(withURL url:URL, maxSize:Int) { 20 | imageLoader.load(url:url) 21 | self.maxSize = maxSize 22 | } 23 | 24 | var body: some View { 25 | VStack { 26 | Image(nsImage: image) 27 | .resizable() 28 | .aspectRatio(contentMode: .fit) 29 | } 30 | .onReceive(imageLoader.didChange) { data in 31 | if maxSize > 0 { 32 | if let smallImage = getSmallImage(fromData:data) { 33 | self.image = smallImage 34 | } 35 | } 36 | else { 37 | self.image = NSImage(data: data) ?? NSImage() 38 | } 39 | } 40 | } 41 | 42 | /// maximum size of the image 43 | /// If it is > 0 getSmallImage is called 44 | private var maxSize:Int = 0 45 | 46 | /// Return a smaller image from a Data object containing an image 47 | /// The size of the image is determined by the maxSize variable 48 | /// - Parameter data: The image Data 49 | /// - Returns: The optional resized image is it was possible to convert Data to an NSImage 50 | private func getSmallImage(fromData data:Data) -> NSImage? { 51 | guard let ciImage = CIImage(data: data), 52 | let cgImage = CGImage.createFrom(ciImage: ciImage) else { return nil } 53 | let resizedImage = cgImage.resize(maxSize: maxSize) 54 | let nsImage = NSImage(cgImage: resizedImage, 55 | size: CGSize(width: resizedImage.width, height: resizedImage.height)) 56 | return nsImage 57 | } 58 | } 59 | 60 | struct ImageView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | ImageView(withURL: URL(string:"")!) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/ImageViewLazy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageViewLazy.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 25/02/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ImageViewLazy: View { 11 | @ObservedObject var imageLoader = ImageLoader() 12 | @State var image:NSImage? 13 | 14 | init(withURL url:URL) { 15 | imageURL = url 16 | } 17 | 18 | init(withURL url:URL, maxSize:Int) { 19 | imageURL = url 20 | } 21 | 22 | var body: some View { 23 | VStack { 24 | if image != nil { 25 | Image(nsImage: image!) 26 | .resizable() 27 | .aspectRatio(contentMode: .fit) 28 | } 29 | else { 30 | Image(nsImage: NSImage()) 31 | .resizable() 32 | } 33 | }.onAppear { 34 | if image == nil { 35 | imageLoader.load(url: imageURL) 36 | } 37 | }.onReceive(imageLoader.didChange) { data in 38 | self.image = NSImage(data: data) 39 | } 40 | } 41 | 42 | private var imageURL:URL 43 | } 44 | 45 | struct ImageViewLazy_Previews: PreviewProvider { 46 | static var previews: some View { 47 | ImageViewLazy(withURL: URL(string:"")!) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/MenuCommands.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuCommands.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 08/02/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MenuCommands: Commands { 11 | var commandsHandler:MenuCommandsHandler 12 | 13 | var body: some Commands { 14 | CommandGroup(replacing: CommandGroupPlacement.newItem) { 15 | // replace with nothing so we don't have to deal with multiple windows 16 | } 17 | CommandGroup(after: CommandGroupPlacement.newItem) { 18 | Button("Open...") { 19 | commandsHandler.openCommand() 20 | } 21 | Button("Show Favorites") { 22 | commandsHandler.showFavoritesCommand() 23 | } 24 | } 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/SingleEntryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleEntryView.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 09/02/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SingleEntryView: View { 11 | @ObservedObject var viewModel: SingleEntryViewModel 12 | 13 | var body: some View { 14 | if entry.type == .directory { 15 | HStack { 16 | Image(systemName: "folder") 17 | .font(.largeTitle) 18 | Text(entry.name) 19 | } 20 | } 21 | else { 22 | HStack { 23 | ThumbnailView(withURL: entry.fileURL, maxSize: 200) 24 | Text(entry.name) 25 | Button(action: viewModel.buttonAction, label: { 26 | Image(systemName:favoriteImageName) 27 | }).buttonStyle(PlainButtonStyle()) 28 | } 29 | } 30 | } 31 | 32 | private var entry: FileEntry { 33 | viewModel.entry 34 | } 35 | 36 | private var favoriteImageName:String { 37 | entry.isFavorite == true ? "star.fill" : "star" 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/SingleImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleImageView.swift 3 | // SimpleImageViewer 4 | // 5 | // Created by Gualtiero Frigerio on 05/02/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SingleImageView: View { 11 | @ObservedObject var viewModel: SingleImageViewModel 12 | var containerSize: CGSize 13 | 14 | var body: some View { 15 | ImageView(withURL: viewModel.imageURL) 16 | .frame(width: containerSize.width * viewModel.scale, 17 | height: containerSize.height * viewModel.scale) 18 | .gesture(MagnificationGesture() 19 | .onChanged { value in 20 | viewModel.magnificationValue = value 21 | } 22 | .onEnded{ Value in 23 | viewModel.endedMagnification() 24 | } 25 | ) 26 | Text(viewModel.imageURL.lastPathComponent) 27 | } 28 | } 29 | 30 | struct SingleImageViewGestureState: View { 31 | var imageURL: URL 32 | var containerSize: CGSize 33 | 34 | @GestureState var scale: CGFloat = 1.0 35 | 36 | var body: some View { 37 | ImageView(withURL: imageURL) 38 | .frame(width: containerSize.width * scale, 39 | height: containerSize.height * scale) 40 | .gesture(MagnificationGesture() 41 | .updating($scale) { currentState, gestureState, transaction in 42 | gestureState = currentState 43 | } 44 | ) 45 | Text(imageURL.lastPathComponent) 46 | } 47 | } 48 | 49 | 50 | struct SingleImageView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | SingleImageView(viewModel: SingleImageViewModel(imageURL: URL(string: "")!), 53 | containerSize: CGSize(width: 0, height: 0)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /SimpleImageViewer/Views/ThumbnailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThumbnailView.swift 3 | // ThumbnailView 4 | // 5 | // Created by Gualtiero Frigerio on 23/08/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ThumbnailView: View { 11 | @ObservedObject var thumbnailLoader = ThumbnailLoader() 12 | 13 | init(withURL url: URL, maxSize: Int) { 14 | thumbnailLoader.loadThumbnail(url: url, maxSize: maxSize) 15 | } 16 | 17 | var body: some View { 18 | Image(nsImage: thumbnailLoader.image) 19 | } 20 | 21 | 22 | } 23 | 24 | fileprivate let testURL = URL(string: "")! 25 | 26 | struct ThumbnailView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | ThumbnailView(withURL: testURL, maxSize: 200) 29 | } 30 | } 31 | --------------------------------------------------------------------------------