diff --git a/Example/PlaybookExample.xcodeproj/project.pbxproj b/Example/PlaybookExample.xcodeproj/project.pbxproj index 082f568..31242bb 100644 --- a/Example/PlaybookExample.xcodeproj/project.pbxproj +++ b/Example/PlaybookExample.xcodeproj/project.pbxproj @@ -983,7 +983,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1043,7 +1043,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Example/SampleComponent/Apple - Working with UI Controls/Profiles/HikeView.swift b/Example/SampleComponent/Apple - Working with UI Controls/Profiles/HikeView.swift index c76b771..1470025 100644 --- a/Example/SampleComponent/Apple - Working with UI Controls/Profiles/HikeView.swift +++ b/Example/SampleComponent/Apple - Working with UI Controls/Profiles/HikeView.swift @@ -24,7 +24,6 @@ public struct HikeView: View { HStack { HikeGraph(hike: hike, path: \.elevation) .frame(width: 50, height: 30) - .animation(nil) VStack(alignment: .leading) { Text(verbatim: hike.name) diff --git a/Example/SampleComponent/Apple - Working with UI Controls/Supporting Views/GraphCapsule.swift b/Example/SampleComponent/Apple - Working with UI Controls/Supporting Views/GraphCapsule.swift index 3dfba15..e63928f 100644 --- a/Example/SampleComponent/Apple - Working with UI Controls/Supporting Views/GraphCapsule.swift +++ b/Example/SampleComponent/Apple - Working with UI Controls/Supporting Views/GraphCapsule.swift @@ -7,7 +7,7 @@ A single line in the graph. import SwiftUI -public struct GraphCapsule: View { +public struct GraphCapsule: View, Equatable { public var index: Int public var height: CGFloat public var range: Range @@ -33,7 +33,7 @@ public struct GraphCapsule: View { .fill(color) .frame(height: height * heightRatio, alignment: .bottom) .offset(x: 0, y: height * -offsetRatio) - .animation(animation) + .animation(animation, value: self) } public init( diff --git a/Example/SamplePlaybook/Scenarios/AllScenarios.swift b/Example/SamplePlaybook/Scenarios/AllScenarios.swift index 982ff66..8ccce16 100644 --- a/Example/SamplePlaybook/Scenarios/AllScenarios.swift +++ b/Example/SamplePlaybook/Scenarios/AllScenarios.swift @@ -1,5 +1,6 @@ import Playbook import SampleComponent +import SwiftUI struct AllScenarios: ScenarioProvider { static func addScenarios(into playbook: Playbook) { diff --git a/Example/project.yml b/Example/project.yml index f6cdf42..e3bf412 100644 --- a/Example/project.yml +++ b/Example/project.yml @@ -7,7 +7,7 @@ options: createIntermediateGroups: true bundleIdPrefix: app.playbook-ui.Example deploymentTarget: - iOS: 13.0 + iOS: 15.0 settings: CODE_SIGNING_REQUIRED: NO diff --git a/Playbook.xcodeproj/project.pbxproj b/Playbook.xcodeproj/project.pbxproj index d50ea98..9fb93ad 100644 --- a/Playbook.xcodeproj/project.pbxproj +++ b/Playbook.xcodeproj/project.pbxproj @@ -9,77 +9,81 @@ /* Begin PBXBuildFile section */ 04DAC4EBEE89D365F72AD5DB /* SnapshotSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023425CAEE4AA111BE174F66 /* SnapshotSupport.swift */; }; 04E6DA15062197311CB80A23 /* ScenarioProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E1EB13B5ED8DD48245B1E8 /* ScenarioProvider.swift */; }; - 0D0659EBE8EF0971AF40405D /* Scheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C24C0BB3864D3AF550A05D /* Scheduler.swift */; }; - 0E1A77DB134CE41112BE7EFB /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BDCEF4FDC1DE7D50B6E73D /* Counter.swift */; }; - 0FEB2071F01AF72A62527F63 /* Highlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA6FEAB12579B7940EA763BB /* Highlight.swift */; }; - 1045A54325FE670A3EC64866 /* SearchedListData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF2924992CEC971C20B6EC33 /* SearchedListData.swift */; }; + 08DB4240FAF948F16398C1C9 /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = C509DD2858530167BEC5BB11 /* SearchResult.swift */; }; + 0A26157EE6E7B940067CA211 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 559B640B10AF07E0E4755344 /* SearchBar.swift */; }; + 14CB92EA193DE1EEB60DC590 /* ColorSchemePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F44D4BCEA15399D487993CA /* ColorSchemePicker.swift */; }; 14DBC83A78A328C77BF96AAF /* Playbook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08B7BB8752007FC3BB78EC8A /* Playbook.framework */; }; - 1561DDA840821DF8807FF006 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636464CE7774B71958BDBCD2 /* Extensions.swift */; }; 1742749E8A4E3AB081B5DCB2 /* PlaybookUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0B038E054D3A0F626DD32C93 /* PlaybookUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 2336557C8E97894E4737A528 /* ScenarioDisplayStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621233CF50A0017066623146 /* ScenarioDisplayStore.swift */; }; - 25C326C9B1CBC90FC989C264 /* CatalogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6EC7EA43758CD4E10A70A8 /* CatalogStore.swift */; }; - 2835B28061CBB1F908CE8D14 /* SerialMainDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70BEAC578E82D5EE82664DA2 /* SerialMainDispatcher.swift */; }; + 17740091AF2876BEA30F0E68 /* HighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBE2887B8D2BD7EA50CD94B /* HighlightText.swift */; }; + 1F6EB9E17EC82DFFE52C8FE9 /* MaterialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A9B46FFAE9273D84C8A395 /* MaterialView.swift */; }; 2C4D9ABC6FC9696E42BE8080 /* PlaybookGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B0431D91C939D181493ECC /* PlaybookGallery.swift */; }; 334B859A3103A83A4EC996BF /* GalleryScenarios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BAD3FE8D577886981218938 /* GalleryScenarios.swift */; }; 341406194DDF8C7A82AB96D0 /* ScenarioContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CD111722A3B8067BFE548 /* ScenarioContext.swift */; }; 358D72B04367A43CD12978FD /* ScenarioViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D5B1C44FC9E87FD3237459 /* ScenarioViewController.swift */; }; - 3DFE3E1AF8E3691457DF156C /* SnapshotLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7C02BA0BA547162205F277 /* SnapshotLoader.swift */; }; + 3D179776ADD757235527A929 /* GalleryThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C668330D834FEE619E6735 /* GalleryThumbnail.swift */; }; + 4104FE671C498D67FB0134C3 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B12B3F422F9EE7600CA7B8C /* ImageCache.swift */; }; 43EC47CA52837935B25B6A54 /* Playbook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08B7BB8752007FC3BB78EC8A /* Playbook.framework */; }; - 4F942C3581B6150EC113B378 /* ScenarioDisplaySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8FCF24068A7FDD9BAD27ECA /* ScenarioDisplaySheet.swift */; }; + 4C55A5EE142078443D368124 /* CatalogScenarioRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A940D28E31EFAE5B3694CEE /* CatalogScenarioRow.swift */; }; + 4D66B6CDF20B4B1C3DEF6790 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED586C95333C4B752C85423 /* UIImage.swift */; }; 4FD59723F2D559B839F6AE9D /* ScenarioSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5527163A80E0C62E2C9C19F /* ScenarioSwiftUI.swift */; }; 509D6D27E6A76DCBDEE64226 /* Export.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51782585DE3DFFD2FDB8684B /* Export.swift */; }; - 59BC00DE37B8F0ED2324F9E6 /* ScenarioDisplayList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF07EECC1D23D59334517855 /* ScenarioDisplayList.swift */; }; + 5494FCBB0627B107CAF044DB /* SelectData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2532CFEE0AB6105AEB5B74E2 /* SelectData.swift */; }; + 56FFC73C5EA4C3F7AB583136 /* SearchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D26212C25465F186B196A9E9 /* SearchState.swift */; }; 5B06509DE08626C2143A51C7 /* PlaybookUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0B038E054D3A0F626DD32C93 /* PlaybookUI.framework */; }; 5CEC9BCEB07DA6F388499754 /* SnapshotWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B09750B60723BB88EB4E0BE /* SnapshotWindow.swift */; }; - 5D84ECFA16954ACC9A0E1141 /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBE0D7EADE227862CB3EB1F /* TableView.swift */; }; 5DC9DF46A93B504BEC043D62 /* Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66200EEB348D4A81C09138E5 /* Snapshot.swift */; }; - 663722E1FA2A87F28409489D /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC9723566343A9120E2BD3D /* SearchResult.swift */; }; 668D80F30C0A09A5667C592A /* PlaybookCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA762F5B447EF9828E1F86F2 /* PlaybookCatalog.swift */; }; - 6BC169CC10D3F962E48C7A60 /* ScenarioContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EC4A04F778E41A014D8DD46 /* ScenarioContentView.swift */; }; 6CF7056823EA1730746AB145 /* ScenarioViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F913250C8A5AB7148459FEC6 /* ScenarioViewControllerTests.swift */; }; 6FC965BE651532324127C71C /* ScenariosBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97823B5F50AA1F1689DB8F56 /* ScenariosBuilder.swift */; }; + 71721E7FA3A9FC3017EEA4FC /* Spacer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEE9E33C79F43CEDD6BA712 /* Spacer.swift */; }; 72DDF1BB8E77EF11CB9BB4FC /* ScenarioSwiftUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB51C85B7D63CEF5DC83B60 /* ScenarioSwiftUITests.swift */; }; + 748569229F0F50D9C36EE2E9 /* UnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B5E679D86934A2FB5A0856 /* UnavailableView.swift */; }; 78E68D7FE09DC81B3F3659F7 /* Playbook.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 08B7BB8752007FC3BB78EC8A /* Playbook.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 795BBCD562B512FC4ED96448 /* CatalogBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7DE56F774FFD4DB73EBD37 /* CatalogBarItem.swift */; }; - 80BBDCCF2C1580F0F508ED73 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1721044B445E0A9326CA6D21 /* Atomic.swift */; }; - 819E05B5E7ABEB948250AD8C /* SearchedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369C805F890E0F0A27A44B5F /* SearchedData.swift */; }; - 88C7D7A743A333E7DF642F54 /* ScenarioSearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49057856849F915AB692364 /* ScenarioSearchStore.swift */; }; + 7AF75286918869ED0516B47D /* CatalogTop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4846AD7448E08DBA3E2C18A5 /* CatalogTop.swift */; }; + 8298234AB8AC2B9046BE09D0 /* GalleryKindRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE9B3C751E8D95A68E247DF /* GalleryKindRow.swift */; }; + 8C596670DA8078B4CA2AA743 /* CatalogSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C925FCA9E427334E3128E21D /* CatalogSplit.swift */; }; + 8DDAA3EF4EB3E70B9360432F /* Separator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11067B4E4A9D6494867D706A /* Separator.swift */; }; 8E518A8B8563E1B97B7A61D7 /* PlaybookSnapshot.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 96A0C0FCA9FA7F91E01BB5A7 /* PlaybookSnapshot.framework */; }; 8FB5292F077C2933737D8411 /* Export.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B9EFFA662D997B8B95EE74 /* Export.swift */; }; 9399EAA20EE0DD10A8D65C9B /* SnapshotWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0032B9D0F6976A6677402EB0 /* SnapshotWaiter.swift */; }; - 97F1D39B80BA6FC503E6C1DB /* KeyboardDismissProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D5B5D2F570B29D57807A44 /* KeyboardDismissProxy.swift */; }; 99904F2A1ECCD16DC130B51A /* OrderedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25344536B72D76889805B50D /* OrderedStorage.swift */; }; + 99A112D26891F0ED999FE27B /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A05522F96443D07E69B0DD3 /* ImageLoader.swift */; }; 9AD198DB3869534A7E503A66 /* Playbook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08B7BB8752007FC3BB78EC8A /* Playbook.framework */; }; - 9C10E142F8D14B64C893B9BD /* ScenarioDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39688046A26BC35A5FDFC672 /* ScenarioDisplay.swift */; }; - A28E3CC1C92EE68DEE3A627E /* ImageSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E955D716921B9CE34F4E35AC /* ImageSharingView.swift */; }; - A3236C9B14BBF8D34C16BC45 /* CatalogSplitStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E59E35FC704B6D9414A6DC /* CatalogSplitStyle.swift */; }; + 9CBF0652A467E45FB7187988 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2169FF71AF568BF427357 /* UIColor.swift */; }; + A1CD039E4B6D017210DEFF0E /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391B584F5DA7B584216E4619 /* View.swift */; }; + AD7EFE7D41A4801FEABBF156 /* CatalogDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC40B940F3E03A47FEC3EC8 /* CatalogDrawer.swift */; }; + AE0DE7D74E721B0D254353B2 /* ScenarioContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B446C3A548A2DC847F032CA0 /* ScenarioContentView.swift */; }; AF9004569675F2D36F6108AE /* ScenarioStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CEC4F17D84235922932498 /* ScenarioStore.swift */; }; AFD49E9D98CDDBEBAA1D9E3A /* ScenariosBuildable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36894DCD112EFAAEE670D92 /* ScenariosBuildable.swift */; }; + B1B7C962BA824C7739DD7486 /* ShareState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2681920272C74A1032D3A4 /* ShareState.swift */; }; + B257A455408A87DD7A0D9F6B /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 556DC8B6487B074BB222CDA9 /* Counter.swift */; }; B366B5D2A9ECB2E5289FDCD1 /* AllScenarios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 302C484FF86103B73D1C1122 /* AllScenarios.swift */; }; - B4384D25712D57EBEDE149F6 /* CatalogDrawerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D53C09A49B19955FA86BE /* CatalogDrawerStyle.swift */; }; - B89DF49D830D61DA0B3C084D /* Drawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65E03F37BA759FC6E55D1C2 /* Drawer.swift */; }; + B44C35245CFBC0043183D67C /* ImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6720986B0B52A13B30D528 /* ImageSource.swift */; }; B945D03AD556F3F8B3161147 /* ScenarioStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81538A32C0A57BA96700E55E /* ScenarioStoreTests.swift */; }; BE7BF04B28E47309D2D7608D /* ScenarioKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = F830987472F2FBD2C3BA7284 /* ScenarioKind.swift */; }; BF44BEAFAA1183BE4B9952AF /* Scenario.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E50413A08A9CF285D97A65C /* Scenario.swift */; }; - C0F32DC787F41414E09C37FB /* HorizontalSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5576F4CB67593B259E12C387 /* HorizontalSeparator.swift */; }; C51E0AB09C31FA61FE7D90BF /* ExtraScenarios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D23A73BBBCC7E6D4027CDB /* ExtraScenarios.swift */; }; - C6A0A71A7D1DB2BECD5B4A2D /* ScenarioSearchTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068FE06929659477F04AA176 /* ScenarioSearchTree.swift */; }; + C57746152ACAC8D242E7992A /* GalleryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEC649C8437116C1FFCA36D9 /* GalleryState.swift */; }; CF8244A4AB05E5ED99B68742 /* SnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B59B5A8B33031C6AF8A049E /* SnapshotTests.swift */; }; D016FBABE74D6996F5648DDC /* SnapshotDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C5F051089936C546DFBB95 /* SnapshotDevice.swift */; }; - D45CD95A63FC59D24E4F5E65 /* GalleryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7F9F34307C4DEEDF210F9 /* GalleryStore.swift */; }; + D97CC8D3208EEDF6B11EF898 /* SearchedKindData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73329E8A55711E5737C66ACD /* SearchedKindData.swift */; }; D99764328B0478E65C0FDEB0 /* ScenarioName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F065DE6EA72726CE6FE821D /* ScenarioName.swift */; }; DDF763A23C1AE9464827CCCE /* ScenarioLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF835DB024B80E2FADFFD0FC /* ScenarioLayout.swift */; }; - E1680C7D25ADB69BF62A6BF5 /* Blur.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2CD5659122DCA1911F566F /* Blur.swift */; }; + DE001E8A450A047BE1F2AB7C /* CatalogKindRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B268BD03E4ADE4AE1F5BF269 /* CatalogKindRow.swift */; }; E5C77A25F77EC8ED48C13512 /* SnapshotError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108C38EAC8F960E10B0FA627 /* SnapshotError.swift */; }; E7597D8F364FF8D2CD4DF584 /* PlaybookTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9370D44977281AAEBF6DEF44 /* PlaybookTests.swift */; }; + EC542FAB97124C8728AE06DD /* CatalogState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8908E9DA16AB4BA3A659914C /* CatalogState.swift */; }; + EC7FB83606201C8AAA15D098 /* GalleryDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 676CCC73E9C6DED60834DCF0 /* GalleryDetail.swift */; }; + EE5F75875FDD118B4A5F0BF8 /* CatalogBottomBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698312DFD253858E0CE488E6 /* CatalogBottomBar.swift */; }; EEBAD73009B0B8CC6B4B6F76 /* TestTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8700BC5E0B042913C8C10CBA /* TestTool.swift */; }; + F16829BEF3BB1173FFFA18B0 /* SearchedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F412670B8570C6C62C7875E /* SearchedData.swift */; }; F566A7702FD9DDCF20F450F2 /* CatalogScenarios.swift in Sources */ = {isa = PBXBuildFile; fileRef = D15384CB93B122425863A487 /* CatalogScenarios.swift */; }; - F68746E7D5745E764F19D377 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4E86485B651D2EE3F6A2A6 /* EnvironmentValues.swift */; }; + F7D7D9ADA3D68C3C17B9968D /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47949D75D9EE00D1B41E460 /* Image.swift */; }; F9DF0B63F62E3A7B5ED2E619 /* Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB78746E7DE13CB4BBD42FC /* Mocks.swift */; }; - FAB1302DAAB4D0C5CF83C1C1 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4351CB488B9D56ABCDD4E27A /* SearchBar.swift */; }; + FB311A5E12E4B63A845D274A /* CatalogSearchPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028BA6B86229E5CB18B406C1 /* CatalogSearchPane.swift */; }; FD3C77D1442C7CAADC579CB2 /* Playbook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C5E36DF37E9A2AF379859 /* Playbook.swift */; }; + FE054384773081FAFD2F3A55 /* GalleryDetailTopBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2CB89FE909D51ACCC78465 /* GalleryDetailTopBar.swift */; }; FEE775B5BE1C7E957E0306DA /* PlaybookSnapshot.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 96A0C0FCA9FA7F91E01BB5A7 /* PlaybookSnapshot.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - FF4469309B532A58B10C5EE1 /* WeakReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CED8582C39C01B51301E1CF /* WeakReference.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -139,72 +143,76 @@ /* Begin PBXFileReference section */ 0032B9D0F6976A6677402EB0 /* SnapshotWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotWaiter.swift; sourceTree = ""; }; 023425CAEE4AA111BE174F66 /* SnapshotSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotSupport.swift; sourceTree = ""; }; - 068FE06929659477F04AA176 /* ScenarioSearchTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioSearchTree.swift; sourceTree = ""; }; + 028BA6B86229E5CB18B406C1 /* CatalogSearchPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogSearchPane.swift; sourceTree = ""; }; 08B7BB8752007FC3BB78EC8A /* Playbook.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Playbook.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0B038E054D3A0F626DD32C93 /* PlaybookUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PlaybookUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 108C38EAC8F960E10B0FA627 /* SnapshotError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotError.swift; sourceTree = ""; }; - 1721044B445E0A9326CA6D21 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; - 1CED8582C39C01B51301E1CF /* WeakReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakReference.swift; sourceTree = ""; }; + 11067B4E4A9D6494867D706A /* Separator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Separator.swift; sourceTree = ""; }; + 2532CFEE0AB6105AEB5B74E2 /* SelectData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectData.swift; sourceTree = ""; }; 25344536B72D76889805B50D /* OrderedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedStorage.swift; sourceTree = ""; }; + 29B5E679D86934A2FB5A0856 /* UnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnavailableView.swift; sourceTree = ""; }; + 2B12B3F422F9EE7600CA7B8C /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 2F065DE6EA72726CE6FE821D /* ScenarioName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioName.swift; sourceTree = ""; }; + 2F412670B8570C6C62C7875E /* SearchedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedData.swift; sourceTree = ""; }; + 2F44D4BCEA15399D487993CA /* ColorSchemePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSchemePicker.swift; sourceTree = ""; }; 302C484FF86103B73D1C1122 /* AllScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllScenarios.swift; sourceTree = ""; }; - 369C805F890E0F0A27A44B5F /* SearchedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedData.swift; sourceTree = ""; }; - 377D53C09A49B19955FA86BE /* CatalogDrawerStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogDrawerStyle.swift; sourceTree = ""; }; 385C5E36DF37E9A2AF379859 /* Playbook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playbook.swift; sourceTree = ""; }; - 38F7F9F34307C4DEEDF210F9 /* GalleryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryStore.swift; sourceTree = ""; }; - 39688046A26BC35A5FDFC672 /* ScenarioDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioDisplay.swift; sourceTree = ""; }; - 4351CB488B9D56ABCDD4E27A /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; + 391B584F5DA7B584216E4619 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; + 3FC40B940F3E03A47FEC3EC8 /* CatalogDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogDrawer.swift; sourceTree = ""; }; + 4846AD7448E08DBA3E2C18A5 /* CatalogTop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogTop.swift; sourceTree = ""; }; + 4CEE9E33C79F43CEDD6BA712 /* Spacer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Spacer.swift; sourceTree = ""; }; 51782585DE3DFFD2FDB8684B /* Export.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Export.swift; sourceTree = ""; }; - 5576F4CB67593B259E12C387 /* HorizontalSeparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalSeparator.swift; sourceTree = ""; }; + 556DC8B6487B074BB222CDA9 /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = ""; }; + 559B640B10AF07E0E4755344 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; 56D23A73BBBCC7E6D4027CDB /* ExtraScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraScenarios.swift; sourceTree = ""; }; - 5CC9723566343A9120E2BD3D /* SearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = ""; }; - 621233CF50A0017066623146 /* ScenarioDisplayStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioDisplayStore.swift; sourceTree = ""; }; - 636464CE7774B71958BDBCD2 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 66200EEB348D4A81C09138E5 /* Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Snapshot.swift; sourceTree = ""; }; + 676CCC73E9C6DED60834DCF0 /* GalleryDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetail.swift; sourceTree = ""; }; 68E1EB13B5ED8DD48245B1E8 /* ScenarioProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioProvider.swift; sourceTree = ""; }; + 698312DFD253858E0CE488E6 /* CatalogBottomBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogBottomBar.swift; sourceTree = ""; }; 6B09750B60723BB88EB4E0BE /* SnapshotWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotWindow.swift; sourceTree = ""; }; - 6D6EC7EA43758CD4E10A70A8 /* CatalogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogStore.swift; sourceTree = ""; }; 6E50413A08A9CF285D97A65C /* Scenario.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Scenario.swift; sourceTree = ""; }; - 70BEAC578E82D5EE82664DA2 /* SerialMainDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialMainDispatcher.swift; sourceTree = ""; }; + 6ED586C95333C4B752C85423 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; + 73329E8A55711E5737C66ACD /* SearchedKindData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedKindData.swift; sourceTree = ""; }; 77D5B1C44FC9E87FD3237459 /* ScenarioViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioViewController.swift; sourceTree = ""; }; + 7B2681920272C74A1032D3A4 /* ShareState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareState.swift; sourceTree = ""; }; + 80C668330D834FEE619E6735 /* GalleryThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryThumbnail.swift; sourceTree = ""; }; 81538A32C0A57BA96700E55E /* ScenarioStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioStoreTests.swift; sourceTree = ""; }; + 85A9B46FFAE9273D84C8A395 /* MaterialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaterialView.swift; sourceTree = ""; }; 8700BC5E0B042913C8C10CBA /* TestTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTool.swift; sourceTree = ""; }; + 8908E9DA16AB4BA3A659914C /* CatalogState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogState.swift; sourceTree = ""; }; 89B9EFFA662D997B8B95EE74 /* Export.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Export.swift; sourceTree = ""; }; - 8B7C02BA0BA547162205F277 /* SnapshotLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotLoader.swift; sourceTree = ""; }; - 8EC4A04F778E41A014D8DD46 /* ScenarioContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioContentView.swift; sourceTree = ""; }; 9370D44977281AAEBF6DEF44 /* PlaybookTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybookTests.swift; sourceTree = ""; }; - 95BDCEF4FDC1DE7D50B6E73D /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = ""; }; - 95D5B5D2F570B29D57807A44 /* KeyboardDismissProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardDismissProxy.swift; sourceTree = ""; }; 96A0C0FCA9FA7F91E01BB5A7 /* PlaybookSnapshot.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PlaybookSnapshot.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 97823B5F50AA1F1689DB8F56 /* ScenariosBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenariosBuilder.swift; sourceTree = ""; }; + 9A05522F96443D07E69B0DD3 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; + 9A940D28E31EFAE5B3694CEE /* CatalogScenarioRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogScenarioRow.swift; sourceTree = ""; }; 9B59B5A8B33031C6AF8A049E /* SnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTests.swift; sourceTree = ""; }; 9BAD3FE8D577886981218938 /* GalleryScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryScenarios.swift; sourceTree = ""; }; - A2C24C0BB3864D3AF550A05D /* Scheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Scheduler.swift; sourceTree = ""; }; A5527163A80E0C62E2C9C19F /* ScenarioSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioSwiftUI.swift; sourceTree = ""; }; - A8FCF24068A7FDD9BAD27ECA /* ScenarioDisplaySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioDisplaySheet.swift; sourceTree = ""; }; - ABBE0D7EADE227862CB3EB1F /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; + AA6720986B0B52A13B30D528 /* ImageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSource.swift; sourceTree = ""; }; AEB78746E7DE13CB4BBD42FC /* Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocks.swift; sourceTree = ""; }; + B268BD03E4ADE4AE1F5BF269 /* CatalogKindRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogKindRow.swift; sourceTree = ""; }; + B446C3A548A2DC847F032CA0 /* ScenarioContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioContentView.swift; sourceTree = ""; }; + B47949D75D9EE00D1B41E460 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; B6C5F051089936C546DFBB95 /* SnapshotDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotDevice.swift; sourceTree = ""; }; B6CEC4F17D84235922932498 /* ScenarioStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioStore.swift; sourceTree = ""; }; - C65E03F37BA759FC6E55D1C2 /* Drawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Drawer.swift; sourceTree = ""; }; + BCBE2887B8D2BD7EA50CD94B /* HighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightText.swift; sourceTree = ""; }; + BEC649C8437116C1FFCA36D9 /* GalleryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryState.swift; sourceTree = ""; }; + C509DD2858530167BEC5BB11 /* SearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = ""; }; + C925FCA9E427334E3128E21D /* CatalogSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogSplit.swift; sourceTree = ""; }; CEB51C85B7D63CEF5DC83B60 /* ScenarioSwiftUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioSwiftUITests.swift; sourceTree = ""; }; - CF2924992CEC971C20B6EC33 /* SearchedListData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchedListData.swift; sourceTree = ""; }; D15384CB93B122425863A487 /* CatalogScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogScenarios.swift; sourceTree = ""; }; + D26212C25465F186B196A9E9 /* SearchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchState.swift; sourceTree = ""; }; D36894DCD112EFAAEE670D92 /* ScenariosBuildable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenariosBuildable.swift; sourceTree = ""; }; D63CD111722A3B8067BFE548 /* ScenarioContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioContext.swift; sourceTree = ""; }; - DE2CD5659122DCA1911F566F /* Blur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Blur.swift; sourceTree = ""; }; - DF07EECC1D23D59334517855 /* ScenarioDisplayList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioDisplayList.swift; sourceTree = ""; }; - E2E59E35FC704B6D9414A6DC /* CatalogSplitStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogSplitStyle.swift; sourceTree = ""; }; - E955D716921B9CE34F4E35AC /* ImageSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSharingView.swift; sourceTree = ""; }; + DA2CB89FE909D51ACCC78465 /* GalleryDetailTopBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetailTopBar.swift; sourceTree = ""; }; + DDF2169FF71AF568BF427357 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; EA762F5B447EF9828E1F86F2 /* PlaybookCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybookCatalog.swift; sourceTree = ""; }; - EB7DE56F774FFD4DB73EBD37 /* CatalogBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogBarItem.swift; sourceTree = ""; }; EC23506D3CC07FABB1992787 /* Playbook-Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Playbook-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - F49057856849F915AB692364 /* ScenarioSearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioSearchStore.swift; sourceTree = ""; }; + ECE9B3C751E8D95A68E247DF /* GalleryKindRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryKindRow.swift; sourceTree = ""; }; F6B0431D91C939D181493ECC /* PlaybookGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybookGallery.swift; sourceTree = ""; }; F830987472F2FBD2C3BA7284 /* ScenarioKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioKind.swift; sourceTree = ""; }; F913250C8A5AB7148459FEC6 /* ScenarioViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioViewControllerTests.swift; sourceTree = ""; }; - FA6FEAB12579B7940EA763BB /* Highlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Highlight.swift; sourceTree = ""; }; - FF4E86485B651D2EE3F6A2A6 /* EnvironmentValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; FF835DB024B80E2FADFFD0FC /* ScenarioLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenarioLayout.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -321,37 +329,10 @@ 6A92FC6CA051A044FE8B4F24 /* Internal */ = { isa = PBXGroup; children = ( - 1721044B445E0A9326CA6D21 /* Atomic.swift */, - DE2CD5659122DCA1911F566F /* Blur.swift */, - EB7DE56F774FFD4DB73EBD37 /* CatalogBarItem.swift */, - 377D53C09A49B19955FA86BE /* CatalogDrawerStyle.swift */, - E2E59E35FC704B6D9414A6DC /* CatalogSplitStyle.swift */, - 6D6EC7EA43758CD4E10A70A8 /* CatalogStore.swift */, - 95BDCEF4FDC1DE7D50B6E73D /* Counter.swift */, - C65E03F37BA759FC6E55D1C2 /* Drawer.swift */, - FF4E86485B651D2EE3F6A2A6 /* EnvironmentValues.swift */, - 636464CE7774B71958BDBCD2 /* Extensions.swift */, - 38F7F9F34307C4DEEDF210F9 /* GalleryStore.swift */, - FA6FEAB12579B7940EA763BB /* Highlight.swift */, - 5576F4CB67593B259E12C387 /* HorizontalSeparator.swift */, - E955D716921B9CE34F4E35AC /* ImageSharingView.swift */, - 95D5B5D2F570B29D57807A44 /* KeyboardDismissProxy.swift */, - 8EC4A04F778E41A014D8DD46 /* ScenarioContentView.swift */, - 39688046A26BC35A5FDFC672 /* ScenarioDisplay.swift */, - DF07EECC1D23D59334517855 /* ScenarioDisplayList.swift */, - A8FCF24068A7FDD9BAD27ECA /* ScenarioDisplaySheet.swift */, - 621233CF50A0017066623146 /* ScenarioDisplayStore.swift */, - F49057856849F915AB692364 /* ScenarioSearchStore.swift */, - 068FE06929659477F04AA176 /* ScenarioSearchTree.swift */, - A2C24C0BB3864D3AF550A05D /* Scheduler.swift */, - 4351CB488B9D56ABCDD4E27A /* SearchBar.swift */, - 369C805F890E0F0A27A44B5F /* SearchedData.swift */, - CF2924992CEC971C20B6EC33 /* SearchedListData.swift */, - 5CC9723566343A9120E2BD3D /* SearchResult.swift */, - 70BEAC578E82D5EE82664DA2 /* SerialMainDispatcher.swift */, - 8B7C02BA0BA547162205F277 /* SnapshotLoader.swift */, - ABBE0D7EADE227862CB3EB1F /* TableView.swift */, - 1CED8582C39C01B51301E1CF /* WeakReference.swift */, + 9F0631225653E9350F11DD57 /* Entities */, + EC193057B062B7F025F74131 /* State */, + FECB4EC25441B5B6E1EC4415 /* Utilities */, + BCB10B60C3EF6BAD57782CAD /* Views */, ); path = Internal; sourceTree = ""; @@ -381,6 +362,43 @@ path = Internal; sourceTree = ""; }; + 9F0631225653E9350F11DD57 /* Entities */ = { + isa = PBXGroup; + children = ( + 2F412670B8570C6C62C7875E /* SearchedData.swift */, + 73329E8A55711E5737C66ACD /* SearchedKindData.swift */, + C509DD2858530167BEC5BB11 /* SearchResult.swift */, + 2532CFEE0AB6105AEB5B74E2 /* SelectData.swift */, + ); + path = Entities; + sourceTree = ""; + }; + BCB10B60C3EF6BAD57782CAD /* Views */ = { + isa = PBXGroup; + children = ( + 698312DFD253858E0CE488E6 /* CatalogBottomBar.swift */, + 3FC40B940F3E03A47FEC3EC8 /* CatalogDrawer.swift */, + B268BD03E4ADE4AE1F5BF269 /* CatalogKindRow.swift */, + 9A940D28E31EFAE5B3694CEE /* CatalogScenarioRow.swift */, + 028BA6B86229E5CB18B406C1 /* CatalogSearchPane.swift */, + C925FCA9E427334E3128E21D /* CatalogSplit.swift */, + 4846AD7448E08DBA3E2C18A5 /* CatalogTop.swift */, + 2F44D4BCEA15399D487993CA /* ColorSchemePicker.swift */, + 556DC8B6487B074BB222CDA9 /* Counter.swift */, + 676CCC73E9C6DED60834DCF0 /* GalleryDetail.swift */, + DA2CB89FE909D51ACCC78465 /* GalleryDetailTopBar.swift */, + ECE9B3C751E8D95A68E247DF /* GalleryKindRow.swift */, + 80C668330D834FEE619E6735 /* GalleryThumbnail.swift */, + BCBE2887B8D2BD7EA50CD94B /* HighlightText.swift */, + 85A9B46FFAE9273D84C8A395 /* MaterialView.swift */, + B446C3A548A2DC847F032CA0 /* ScenarioContentView.swift */, + 559B640B10AF07E0E4755344 /* SearchBar.swift */, + 11067B4E4A9D6494867D706A /* Separator.swift */, + 29B5E679D86934A2FB5A0856 /* UnavailableView.swift */, + ); + path = Views; + sourceTree = ""; + }; C71D05311B2E181F61A0F029 /* Tests */ = { isa = PBXGroup; children = ( @@ -409,6 +427,32 @@ path = SnapshotSupport; sourceTree = ""; }; + EC193057B062B7F025F74131 /* State */ = { + isa = PBXGroup; + children = ( + 8908E9DA16AB4BA3A659914C /* CatalogState.swift */, + BEC649C8437116C1FFCA36D9 /* GalleryState.swift */, + D26212C25465F186B196A9E9 /* SearchState.swift */, + 7B2681920272C74A1032D3A4 /* ShareState.swift */, + ); + path = State; + sourceTree = ""; + }; + FECB4EC25441B5B6E1EC4415 /* Utilities */ = { + isa = PBXGroup; + children = ( + B47949D75D9EE00D1B41E460 /* Image.swift */, + 2B12B3F422F9EE7600CA7B8C /* ImageCache.swift */, + 9A05522F96443D07E69B0DD3 /* ImageLoader.swift */, + AA6720986B0B52A13B30D528 /* ImageSource.swift */, + 4CEE9E33C79F43CEDD6BA712 /* Spacer.swift */, + DDF2169FF71AF568BF427357 /* UIColor.swift */, + 6ED586C95333C4B752C85423 /* UIImage.swift */, + 391B584F5DA7B584216E4619 /* View.swift */, + ); + path = Utilities; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -581,40 +625,44 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 80BBDCCF2C1580F0F508ED73 /* Atomic.swift in Sources */, - E1680C7D25ADB69BF62A6BF5 /* Blur.swift in Sources */, - 795BBCD562B512FC4ED96448 /* CatalogBarItem.swift in Sources */, - B4384D25712D57EBEDE149F6 /* CatalogDrawerStyle.swift in Sources */, - A3236C9B14BBF8D34C16BC45 /* CatalogSplitStyle.swift in Sources */, - 25C326C9B1CBC90FC989C264 /* CatalogStore.swift in Sources */, - 0E1A77DB134CE41112BE7EFB /* Counter.swift in Sources */, - B89DF49D830D61DA0B3C084D /* Drawer.swift in Sources */, - F68746E7D5745E764F19D377 /* EnvironmentValues.swift in Sources */, + EE5F75875FDD118B4A5F0BF8 /* CatalogBottomBar.swift in Sources */, + AD7EFE7D41A4801FEABBF156 /* CatalogDrawer.swift in Sources */, + DE001E8A450A047BE1F2AB7C /* CatalogKindRow.swift in Sources */, + 4C55A5EE142078443D368124 /* CatalogScenarioRow.swift in Sources */, + FB311A5E12E4B63A845D274A /* CatalogSearchPane.swift in Sources */, + 8C596670DA8078B4CA2AA743 /* CatalogSplit.swift in Sources */, + EC542FAB97124C8728AE06DD /* CatalogState.swift in Sources */, + 7AF75286918869ED0516B47D /* CatalogTop.swift in Sources */, + 14CB92EA193DE1EEB60DC590 /* ColorSchemePicker.swift in Sources */, + B257A455408A87DD7A0D9F6B /* Counter.swift in Sources */, 509D6D27E6A76DCBDEE64226 /* Export.swift in Sources */, - 1561DDA840821DF8807FF006 /* Extensions.swift in Sources */, - D45CD95A63FC59D24E4F5E65 /* GalleryStore.swift in Sources */, - 0FEB2071F01AF72A62527F63 /* Highlight.swift in Sources */, - C0F32DC787F41414E09C37FB /* HorizontalSeparator.swift in Sources */, - A28E3CC1C92EE68DEE3A627E /* ImageSharingView.swift in Sources */, - 97F1D39B80BA6FC503E6C1DB /* KeyboardDismissProxy.swift in Sources */, + EC7FB83606201C8AAA15D098 /* GalleryDetail.swift in Sources */, + FE054384773081FAFD2F3A55 /* GalleryDetailTopBar.swift in Sources */, + 8298234AB8AC2B9046BE09D0 /* GalleryKindRow.swift in Sources */, + C57746152ACAC8D242E7992A /* GalleryState.swift in Sources */, + 3D179776ADD757235527A929 /* GalleryThumbnail.swift in Sources */, + 17740091AF2876BEA30F0E68 /* HighlightText.swift in Sources */, + F7D7D9ADA3D68C3C17B9968D /* Image.swift in Sources */, + 4104FE671C498D67FB0134C3 /* ImageCache.swift in Sources */, + 99A112D26891F0ED999FE27B /* ImageLoader.swift in Sources */, + B44C35245CFBC0043183D67C /* ImageSource.swift in Sources */, + 1F6EB9E17EC82DFFE52C8FE9 /* MaterialView.swift in Sources */, 668D80F30C0A09A5667C592A /* PlaybookCatalog.swift in Sources */, 2C4D9ABC6FC9696E42BE8080 /* PlaybookGallery.swift in Sources */, - 6BC169CC10D3F962E48C7A60 /* ScenarioContentView.swift in Sources */, - 9C10E142F8D14B64C893B9BD /* ScenarioDisplay.swift in Sources */, - 59BC00DE37B8F0ED2324F9E6 /* ScenarioDisplayList.swift in Sources */, - 4F942C3581B6150EC113B378 /* ScenarioDisplaySheet.swift in Sources */, - 2336557C8E97894E4737A528 /* ScenarioDisplayStore.swift in Sources */, - 88C7D7A743A333E7DF642F54 /* ScenarioSearchStore.swift in Sources */, - C6A0A71A7D1DB2BECD5B4A2D /* ScenarioSearchTree.swift in Sources */, - 0D0659EBE8EF0971AF40405D /* Scheduler.swift in Sources */, - FAB1302DAAB4D0C5CF83C1C1 /* SearchBar.swift in Sources */, - 663722E1FA2A87F28409489D /* SearchResult.swift in Sources */, - 819E05B5E7ABEB948250AD8C /* SearchedData.swift in Sources */, - 1045A54325FE670A3EC64866 /* SearchedListData.swift in Sources */, - 2835B28061CBB1F908CE8D14 /* SerialMainDispatcher.swift in Sources */, - 3DFE3E1AF8E3691457DF156C /* SnapshotLoader.swift in Sources */, - 5D84ECFA16954ACC9A0E1141 /* TableView.swift in Sources */, - FF4469309B532A58B10C5EE1 /* WeakReference.swift in Sources */, + AE0DE7D74E721B0D254353B2 /* ScenarioContentView.swift in Sources */, + 0A26157EE6E7B940067CA211 /* SearchBar.swift in Sources */, + 08DB4240FAF948F16398C1C9 /* SearchResult.swift in Sources */, + 56FFC73C5EA4C3F7AB583136 /* SearchState.swift in Sources */, + F16829BEF3BB1173FFFA18B0 /* SearchedData.swift in Sources */, + D97CC8D3208EEDF6B11EF898 /* SearchedKindData.swift in Sources */, + 5494FCBB0627B107CAF044DB /* SelectData.swift in Sources */, + 8DDAA3EF4EB3E70B9360432F /* Separator.swift in Sources */, + B1B7C962BA824C7739DD7486 /* ShareState.swift in Sources */, + 71721E7FA3A9FC3017EEA4FC /* Spacer.swift in Sources */, + 9CBF0652A467E45FB7187988 /* UIColor.swift in Sources */, + 4D66B6CDF20B4B1C3DEF6790 /* UIImage.swift in Sources */, + 748569229F0F50D9C36EE2E9 /* UnavailableView.swift in Sources */, + A1CD039E4B6D017210DEFF0E /* View.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Playbook/SnapshotSupport/Internal/SnapshotWindow.swift b/Sources/Playbook/SnapshotSupport/Internal/SnapshotWindow.swift index d2818ca..249f967 100644 --- a/Sources/Playbook/SnapshotSupport/Internal/SnapshotWindow.swift +++ b/Sources/Playbook/SnapshotSupport/Internal/SnapshotWindow.swift @@ -47,14 +47,14 @@ internal final class SnapshotWindow: UIWindow { scenarioViewController.disablesEndAppearanceTransition = true scenarioViewController.shouldStatusBarHidden = true - frame.size = CGSize( - width: scenario.layout.fixedWidth ?? device.size.width, - height: scenario.layout.fixedHeight ?? device.size.height - ) windowLevel = .normal - 1 layer.speed = .greatestFiniteMagnitude rootViewController = scenarioViewController isHidden = false + frame.size = CGSize( + width: scenario.layout.fixedWidth ?? device.size.width, + height: scenario.layout.fixedHeight ?? device.size.height + ) if window != nil { // In iOS 16 and 17, setting `isHidden` nor does not update diff --git a/Sources/PlaybookUI/Internal/Atomic.swift b/Sources/PlaybookUI/Internal/Atomic.swift deleted file mode 100644 index 8f38a19..0000000 --- a/Sources/PlaybookUI/Internal/Atomic.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -@propertyWrapper -internal final class Atomic { - var wrappedValue: Value { - get { modify { $0 } } - set { modify { $0 = newValue } } - } - - private let lock = NSLock() - private var value: Value - - init(wrappedValue: Value) { - self.value = wrappedValue - } - - @discardableResult - func modify(action: (inout Value) -> T) -> T { - lock.lock() - defer { lock.unlock() } - return action(&value) - } - - @discardableResult - func swap(_ newValue: Value) -> Value { - modify { value in - let oldValue = value - value = newValue - return oldValue - } - } -} diff --git a/Sources/PlaybookUI/Internal/Blur.swift b/Sources/PlaybookUI/Internal/Blur.swift deleted file mode 100644 index b695a4d..0000000 --- a/Sources/PlaybookUI/Internal/Blur.swift +++ /dev/null @@ -1,13 +0,0 @@ -import SwiftUI - -internal struct Blur: UIViewRepresentable { - var style: UIBlurEffect.Style - - func makeUIView(context: Context) -> UIVisualEffectView { - UIVisualEffectView() - } - - func updateUIView(_ uiView: UIVisualEffectView, context: Context) { - uiView.effect = UIBlurEffect(style: style) - } -} diff --git a/Sources/PlaybookUI/Internal/CatalogBarItem.swift b/Sources/PlaybookUI/Internal/CatalogBarItem.swift deleted file mode 100644 index 860f3b8..0000000 --- a/Sources/PlaybookUI/Internal/CatalogBarItem.swift +++ /dev/null @@ -1,17 +0,0 @@ -import SwiftUI - -internal struct CatalogBarItem: View { - var image: Image - var insets: EdgeInsets - var action: () -> Void - - var body: some View { - Button(action: action) { - image - .padding(8) - .imageScale(.large) - .foregroundColor(Color(.label)) - .padding(insets) - } - } -} diff --git a/Sources/PlaybookUI/Internal/CatalogDrawerStyle.swift b/Sources/PlaybookUI/Internal/CatalogDrawerStyle.swift deleted file mode 100644 index 8a71ac0..0000000 --- a/Sources/PlaybookUI/Internal/CatalogDrawerStyle.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI - -internal struct CatalogDrawerStyle: View { - var name: String - var searchTree: ScenarioSearchTree - var content: (CatalogBarItem) -> Content - - @EnvironmentObject - private var store: CatalogStore - - public init( - name: String, - searchTree: ScenarioSearchTree, - content: @escaping (CatalogBarItem) -> Content - ) { - self.name = name - self.searchTree = searchTree - self.content = content - } - - var body: some View { - ZStack { - content( - CatalogBarItem(image: Image(symbol: .magnifyingglass), insets: .only(top: 2)) { - self.store.isSearchTreeHidden = false - } - ) - - Drawer(isOpened: isOpened, content: searchTree) - } - } -} - -private extension CatalogDrawerStyle { - var isOpened: Binding { - Binding( - get: { !self.store.isSearchTreeHidden }, - set: { self.store.isSearchTreeHidden = !$0 } - ) - } -} diff --git a/Sources/PlaybookUI/Internal/CatalogSplitStyle.swift b/Sources/PlaybookUI/Internal/CatalogSplitStyle.swift deleted file mode 100644 index 0a6e6b8..0000000 --- a/Sources/PlaybookUI/Internal/CatalogSplitStyle.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI - -internal struct CatalogSplitStyle: View { - var name: String - var searchTree: ScenarioSearchTree - var content: (CatalogBarItem) -> Content - - @EnvironmentObject - private var store: CatalogStore - - public init( - name: String, - searchTree: ScenarioSearchTree, - content: @escaping (CatalogBarItem) -> Content - ) { - self.name = name - self.searchTree = searchTree - self.content = content - } - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - HStack(spacing: 0) { - self.searchTree - - Divider() - .edgesIgnoringSafeArea(.all) - } - .frame(width: self.sidebarWidth(with: geometry)) - - HStack(spacing: 0) { - Spacer.fixed(length: self.store.isSearchTreeHidden ? 0 : self.sidebarWidth(with: geometry)) - - self.content( - CatalogBarItem( - image: Image(symbol: self.store.isSearchTreeHidden ? .sidebarLeft : .rectangle), - insets: .only(top: 2), - action: { self.store.isSearchTreeHidden.toggle() } - ) - ) - } - } - .animation(nil, value: geometry.size) - .animation(.interactiveSpring()) - .transformEnvironment(\.horizontalSizeClass) { sizeClass in - if !self.store.isSearchTreeHidden && geometry.size.width < geometry.size.height { - sizeClass = .compact - } - } - } - } -} - -private extension CatalogSplitStyle { - func sidebarWidth(with geometry: GeometryProxy) -> CGFloat { - geometry.size.width / 3 - } -} diff --git a/Sources/PlaybookUI/Internal/CatalogStore.swift b/Sources/PlaybookUI/Internal/CatalogStore.swift deleted file mode 100644 index b7a141d..0000000 --- a/Sources/PlaybookUI/Internal/CatalogStore.swift +++ /dev/null @@ -1,37 +0,0 @@ -internal final class CatalogStore: ScenarioSearchStore { - var selectedScenario: SearchedData? { - willSet { objectWillChange.send() } - } - - var openedKinds = Set() { - willSet { objectWillChange.send() } - } - - var openedSearchingKinds: Set? { - willSet { objectWillChange.send() } - } - - var shareItem: ImageSharingView.Item? { - willSet { objectWillChange.send() } - } - - var isSearchTreeHidden = false { - willSet { objectWillChange.send() } - } - - init( - playbook: Playbook, - selectedScenario: SearchedData? = nil, - openedKinds: Set = [], - openedSearchingKinds: Set? = nil, - shareItem: ImageSharingView.Item? = nil, - isSearchTreeHidden: Bool = false - ) { - self.selectedScenario = selectedScenario - self.openedKinds = openedKinds - self.openedSearchingKinds = openedSearchingKinds - self.shareItem = shareItem - self.isSearchTreeHidden = isSearchTreeHidden - super.init(playbook: playbook) - } -} diff --git a/Sources/PlaybookUI/Internal/Counter.swift b/Sources/PlaybookUI/Internal/Counter.swift deleted file mode 100644 index 3612f3a..0000000 --- a/Sources/PlaybookUI/Internal/Counter.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftUI - -internal struct Counter: View, Equatable { - var numerator: Int - var denominator: Int - - var body: some View { - HStack(spacing: 0) { - Spacer.zero - - Text("\(numerator) / \(denominator)") - .font(Font.subheadline.monospacedDigit()) - .foregroundColor(Color(.label)) - } - .padding(.top, 8) - .padding(.horizontal, 24) - .animation(nil, value: self) - } -} diff --git a/Sources/PlaybookUI/Internal/Drawer.swift b/Sources/PlaybookUI/Internal/Drawer.swift deleted file mode 100644 index fdbd9bd..0000000 --- a/Sources/PlaybookUI/Internal/Drawer.swift +++ /dev/null @@ -1,112 +0,0 @@ -import SwiftUI - -internal struct Drawer: View { - @Binding - var isOpened: Bool - - var content: Content - - @State - private var dragState = DragState.inactive - - private var keyboardProxy = KeyboardDismissProxy() - - init(isOpened: Binding, content: Content) { - self._isOpened = isOpened - self.content = content - } - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .topLeading) { - Color.black - .opacity(self.openingRatio(with: geometry) * 0.3) - .edgesIgnoringSafeArea(.all) - .onTapGesture(perform: self.close) - - HStack(alignment: .top, spacing: 0) { - self.content - .background(self.withShadow(Color.black.edgesIgnoringSafeArea(.all))) - .frame(width: self.drawerWidth(with: geometry)) - - self.withShadow( - Button(action: self.close) { - Image(symbol: .multiply) - .imageScale(.medium) - .font(Font.title.bold()) - .foregroundColor(.white) - .padding(16) - } - .opacity(self.openingRatio(with: geometry)) - .allowsHitTesting(self.isOpened) - ) - - Spacer.zero - } - .offset(x: self.offset(with: geometry)) - } - .animation(nil, value: geometry.size) - .animation(.interactiveSpring()) - .frame(width: geometry.size.width, height: geometry.size.height) - .gesture(self.dragGesture(with: geometry)) - .dismissKeyboard(by: self.keyboardProxy) - } - } -} - -private extension Drawer { - enum DragState { - case inactive - case dragging(translation: CGFloat) - } - - var shadowRadius: CGFloat { 12 } - - func withShadow(_ content: some View) -> some View { - content.shadow(color: Color.black.opacity(0.3), radius: shadowRadius) - } - - func dragGesture(with geometry: GeometryProxy) -> some Gesture { - let gesture = DragGesture(coordinateSpace: .global) - .onChanged { drag in - let isHorizontalDrag = abs(drag.translation.width) >= abs(drag.translation.height) - self.dragState = isHorizontalDrag ? .dragging(translation: drag.translation.width) : .inactive - self.keyboardProxy.dismissKeyboard() - } - .onEnded { drag in - let ratio = min(1, max(0, -drag.predictedEndTranslation.width / self.drawerWidth(with: geometry))) - self.isOpened = ratio <= 0.5 - self.dragState = .inactive - } - return isOpened ? gesture : nil - } - - func close() { - dragState = .inactive - isOpened = false - keyboardProxy.dismissKeyboard() - } - - func drawerWidth(with geometry: GeometryProxy) -> CGFloat { - geometry.safeAreaInsets.leading + min(geometry.size.width, geometry.size.height) * 0.8 - } - - func hiddenOffset(with geometry: GeometryProxy) -> CGFloat { - -drawerWidth(with: geometry) - geometry.safeAreaInsets.leading - shadowRadius - } - - func offset(with geometry: GeometryProxy) -> CGFloat { - CGFloat(1 - openingRatio(with: geometry)) * hiddenOffset(with: geometry) - } - - func openingRatio(with geometry: GeometryProxy) -> Double { - switch dragState { - case .inactive: - return isOpened ? 1 : 0 - - case .dragging(let translation): - let ratio = translation / drawerWidth(with: geometry) - return Double(min(1, max(0, isOpened ? 1 + ratio : ratio))) - } - } -} diff --git a/Sources/PlaybookUI/Internal/Entities/SearchResult.swift b/Sources/PlaybookUI/Internal/Entities/SearchResult.swift new file mode 100644 index 0000000..c05948e --- /dev/null +++ b/Sources/PlaybookUI/Internal/Entities/SearchResult.swift @@ -0,0 +1,5 @@ +internal struct SearchResult { + let count: Int + let total: Int + let kinds: [SearchedKindData] +} diff --git a/Sources/PlaybookUI/Internal/Entities/SearchedData.swift b/Sources/PlaybookUI/Internal/Entities/SearchedData.swift new file mode 100644 index 0000000..1388ce6 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Entities/SearchedData.swift @@ -0,0 +1,7 @@ +import Playbook + +internal struct SearchedData { + let kind: ScenarioKind + let scenario: Scenario + let highlightRange: Range? +} diff --git a/Sources/PlaybookUI/Internal/Entities/SearchedKindData.swift b/Sources/PlaybookUI/Internal/Entities/SearchedKindData.swift new file mode 100644 index 0000000..a011b59 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Entities/SearchedKindData.swift @@ -0,0 +1,7 @@ +import Playbook + +internal struct SearchedKindData { + let kind: ScenarioKind + let highlightRange: Range? + let scenarios: [SearchedData] +} diff --git a/Sources/PlaybookUI/Internal/Entities/SelectData.swift b/Sources/PlaybookUI/Internal/Entities/SelectData.swift new file mode 100644 index 0000000..dc27fa5 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Entities/SelectData.swift @@ -0,0 +1,15 @@ +import Playbook + +internal struct SelectData: Identifiable { + struct ID: Hashable { + let kind: ScenarioKind + let name: ScenarioName + } + + var id: ID { + ID(kind: kind, name: scenario.name) + } + + let kind: ScenarioKind + let scenario: Scenario +} diff --git a/Sources/PlaybookUI/Internal/EnvironmentValues.swift b/Sources/PlaybookUI/Internal/EnvironmentValues.swift deleted file mode 100644 index 04eb6c4..0000000 --- a/Sources/PlaybookUI/Internal/EnvironmentValues.swift +++ /dev/null @@ -1,31 +0,0 @@ -import SwiftUI - -internal struct GalleryDependency { - var scheduler: SchedulerProtocol - var context: ScenarioContext - - init(scheduler: SchedulerProtocol, context: ScenarioContext) { - self.scheduler = scheduler - self.context = context - } -} - -internal enum GalleryDependencyEnvironmentKey: EnvironmentKey { - static var defaultValue: GalleryDependency { - GalleryDependency( - scheduler: Scheduler(), - context: ScenarioContext( - snapshotWaiter: SnapshotWaiter(), - isSnapshot: false, - screenSize: UIScreen.main.bounds.size - ) - ) - } -} - -internal extension EnvironmentValues { - var galleryDependency: GalleryDependency { - get { self[GalleryDependencyEnvironmentKey.self] } - set { self[GalleryDependencyEnvironmentKey.self] = newValue } - } -} diff --git a/Sources/PlaybookUI/Internal/Extensions.swift b/Sources/PlaybookUI/Internal/Extensions.swift deleted file mode 100644 index 96466bc..0000000 --- a/Sources/PlaybookUI/Internal/Extensions.swift +++ /dev/null @@ -1,86 +0,0 @@ -import SwiftUI - -internal extension UIColor { - static let primaryBlue = UIColor(hex: 0x048DFF) - - static let primaryBackground = UIColor { traitCollection in - traitCollection.userInterfaceStyle == .light ? .white : UIColor(hex: 0x18242D) - } - - static let secondaryBackground = UIColor { traitCollection in - traitCollection.userInterfaceStyle == .light ? .white : UIColor(hex: 0x29373F) - } - - static let scenarioBackground = UIColor { traitCollection in - traitCollection.userInterfaceStyle == .light ? .white : .black - } - - static let highlight = UIColor.systemYellow.withAlphaComponent(0.4) - - convenience init(hex: Int, alpha: CGFloat = 1) { - let red = CGFloat((hex & 0xFF0000) >> 16) / 255 - let green = CGFloat((hex & 0x00FF00) >> 8) / 255 - let blue = CGFloat((hex & 0x0000FF) >> 0) / 255 - - self.init(red: red, green: green, blue: blue, alpha: alpha) - } - - func circleImage(length: CGFloat) -> UIImage { - let size = CGSize(width: length, height: length) - return UIGraphicsImageRenderer(size: size).image { context in - let rect = CGRect(origin: .zero, size: size) - setFill() - context.cgContext.fillEllipse(in: rect) - } - } -} - -internal extension Image { - enum SFSymbols: String { - case book = "book" - case multiply = "multiply" - case chevronRight = "chevron.right" - case bookmarkFill = "bookmark.fill" - case circleFill = "circle.fill" - case sidebarLeft = "sidebar.left" - case rectangle = "rectangle" - case squareAndArrowUp = "square.and.arrow.up" - case magnifyingglass = "magnifyingglass" - } - - init(symbol: SFSymbols) { - self.init(systemName: symbol.rawValue) - } -} - -internal extension Spacer { - static var zero: Spacer { - Spacer(minLength: 0) - } - - static func fixed(length: CGFloat) -> some View { - Spacer(minLength: length).fixedSize() - } -} - -internal extension EdgeInsets { - static func only( - top: CGFloat = 0, - leading: CGFloat = 0, - bottom: CGFloat = 0, - trailing: CGFloat = 0 - ) -> EdgeInsets { - EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing) - } -} - -internal extension UIEdgeInsets { - static func only( - top: CGFloat = 0, - left: CGFloat = 0, - bottom: CGFloat = 0, - right: CGFloat = 0 - ) -> UIEdgeInsets { - UIEdgeInsets(top: top, left: left, bottom: bottom, right: right) - } -} diff --git a/Sources/PlaybookUI/Internal/GalleryStore.swift b/Sources/PlaybookUI/Internal/GalleryStore.swift deleted file mode 100644 index 46a14a0..0000000 --- a/Sources/PlaybookUI/Internal/GalleryStore.swift +++ /dev/null @@ -1,96 +0,0 @@ -import SwiftUI - -internal final class GalleryStore: ScenarioSearchStore { - enum Status { - case standby - case ready - } - - var selectedScenario: SearchedData? { - willSet { objectWillChange.send() } - } - - var status = Status.standby { - willSet { objectWillChange.send() } - } - - var shareItem: ImageSharingView.Item? { - willSet { objectWillChange.send() } - } - - let preSnapshotCountLimit: Int - let snapshotLoader: SnapshotLoaderProtocol - - func prepare() { - switch status { - case .ready: - break - - case .standby: - takeSnapshots() - start() - status = .ready - } - } - - @discardableResult - func takeSnapshots() -> Self { - snapshotLoader.clean() - - playbook.stores.lazy - .flatMap { store in - store.scenarios.map { scenario in - (kind: store.kind, scenario: scenario) - } - } - .prefix(preSnapshotCountLimit) - .forEach { snapshotLoader.takeSnapshot(for: $0.scenario, kind: $0.kind, completion: nil) } - - return self - } - - init( - playbook: Playbook, - preSnapshotCountLimit: Int, - selectedScenario: SearchedData? = nil, - status: Status = .standby, - shareItem: ImageSharingView.Item? = nil, - snapshotLoader: SnapshotLoaderProtocol - ) { - self.preSnapshotCountLimit = preSnapshotCountLimit - self.snapshotLoader = snapshotLoader - self.selectedScenario = selectedScenario - self.status = status - self.shareItem = shareItem - - super.init(playbook: playbook) - } - - convenience init( - playbook: Playbook, - preSnapshotCountLimit: Int, - screenSize: CGSize, - userInterfaceStyle: UIUserInterfaceStyle, - selectedScenario: SearchedData? = nil, - status: Status = .standby, - shareItem: ImageSharingView.Item? = nil - ) { - self.init( - playbook: playbook, - preSnapshotCountLimit: preSnapshotCountLimit, - selectedScenario: selectedScenario, - status: status, - shareItem: shareItem, - snapshotLoader: SnapshotLoader( - name: "app.playbook-ui.SnapshotLoader", - baseDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), - format: .png, - device: SnapshotDevice( - name: "PlaybookCatalog", - size: screenSize, - traitCollection: UITraitCollection(userInterfaceStyle: userInterfaceStyle) - ) - ) - ) - } -} diff --git a/Sources/PlaybookUI/Internal/Highlight.swift b/Sources/PlaybookUI/Internal/Highlight.swift deleted file mode 100644 index cf4fe79..0000000 --- a/Sources/PlaybookUI/Internal/Highlight.swift +++ /dev/null @@ -1,17 +0,0 @@ -import SwiftUI - -internal struct Highlight: View { - var isHighlighted: Bool - - init(_ isHighlighted: Bool) { - self.isHighlighted = isHighlighted - } - - var body: some View { - Group { - if isHighlighted { - Color(.highlight) - } - } - } -} diff --git a/Sources/PlaybookUI/Internal/HorizontalSeparator.swift b/Sources/PlaybookUI/Internal/HorizontalSeparator.swift deleted file mode 100644 index ecdd9eb..0000000 --- a/Sources/PlaybookUI/Internal/HorizontalSeparator.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -internal struct HorizontalSeparator: View { - var body: some View { - Rectangle() - .fill(Color(.quaternarySystemFill)) - .frame(height: 2) - .fixedSize(horizontal: false, vertical: true) - } -} diff --git a/Sources/PlaybookUI/Internal/ImageSharingView.swift b/Sources/PlaybookUI/Internal/ImageSharingView.swift deleted file mode 100644 index e469d57..0000000 --- a/Sources/PlaybookUI/Internal/ImageSharingView.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftUI - -internal struct ImageSharingView: UIViewControllerRepresentable { - struct Item: Identifiable { - var id: Int { image.hash } - var image: UIImage - } - - var item: Item - var onComplete: () -> Void - - func makeUIViewController(context: Context) -> UIActivityViewController { - let uiViewController = UIActivityViewController( - activityItems: [item.image], - applicationActivities: nil - ) - if !Bundle.main.hasPhotoLibraryAddUsageDescription { - uiViewController.excludedActivityTypes = [.saveToCameraRoll] - } - return uiViewController - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { - uiViewController.completionWithItemsHandler = { _, _, _, _ in - self.onComplete() - } - } -} - -private extension Bundle { - var hasPhotoLibraryAddUsageDescription: Bool { - let usage = object(forInfoDictionaryKey: "NSPhotoLibraryAddUsageDescription") as? String - return usage.map { !$0.isEmpty } ?? false - } -} diff --git a/Sources/PlaybookUI/Internal/KeyboardDismissProxy.swift b/Sources/PlaybookUI/Internal/KeyboardDismissProxy.swift deleted file mode 100644 index 332cb0a..0000000 --- a/Sources/PlaybookUI/Internal/KeyboardDismissProxy.swift +++ /dev/null @@ -1,43 +0,0 @@ -import SwiftUI - -internal final class KeyboardDismissProxy { - fileprivate weak var uiView: UIView? - - func dismissKeyboard() { - uiView?.window?.endEditing(true) - } - - init() {} -} - -internal extension View { - func dismissKeyboard(by proxy: KeyboardDismissProxy) -> some View { - background( - HiddenView(proxy: proxy) - .frame(width: 0, height: 0) - .fixedSize() - .opacity(0) - .allowsHitTesting(false) - ) - } -} - -private struct HiddenView: UIViewRepresentable { - var proxy: KeyboardDismissProxy - - init(proxy: KeyboardDismissProxy) { - self.proxy = proxy - } - - func makeUIView(context: Context) -> UIView { - let uiView = UIView() - uiView.backgroundColor = .clear - uiView.isHidden = true - uiView.isUserInteractionEnabled = false - return uiView - } - - func updateUIView(_ uiView: UIView, context: Context) { - proxy.uiView = uiView - } -} diff --git a/Sources/PlaybookUI/Internal/ScenarioContentView.swift b/Sources/PlaybookUI/Internal/ScenarioContentView.swift deleted file mode 100644 index 93dc8ea..0000000 --- a/Sources/PlaybookUI/Internal/ScenarioContentView.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -internal struct ScenarioContentView: UIViewControllerRepresentable { - var kind: ScenarioKind - var scenario: Scenario - var additionalSafeAreaInsets: UIEdgeInsets - - @WeakReference - private var contentUIView: UIView? - - init( - kind: ScenarioKind, - scenario: Scenario, - additionalSafeAreaInsets: UIEdgeInsets = .zero, - contentUIView: WeakReference - ) { - self.kind = kind - self.scenario = scenario - self.additionalSafeAreaInsets = additionalSafeAreaInsets - self._contentUIView = contentUIView - } - - init( - kind: ScenarioKind, - scenario: Scenario, - additionalSafeAreaInsets: UIEdgeInsets = .zero - ) { - self.kind = kind - self.scenario = scenario - self.additionalSafeAreaInsets = additionalSafeAreaInsets - } - - func makeUIViewController(context: Context) -> ScenarioViewController { - let context = ScenarioContext( - snapshotWaiter: SnapshotWaiter(), - isSnapshot: false, - screenSize: UIScreen.main.bounds.size - ) - return ScenarioViewController(context: context) - } - - func updateUIViewController(_ uiViewController: ScenarioViewController, context: Context) { - contentUIView = uiViewController.view - uiViewController.scenario = scenario - uiViewController.additionalSafeAreaInsets = additionalSafeAreaInsets - } -} diff --git a/Sources/PlaybookUI/Internal/ScenarioDisplay.swift b/Sources/PlaybookUI/Internal/ScenarioDisplay.swift deleted file mode 100644 index a5038e9..0000000 --- a/Sources/PlaybookUI/Internal/ScenarioDisplay.swift +++ /dev/null @@ -1,130 +0,0 @@ -import SwiftUI - -internal struct ScenarioDisplay: View { - enum Status { - case `default` - case waitForSnapshot - case loaded(image: UIImage) - case failed - - var isWaitForSnapshot: Bool { - guard case .waitForSnapshot = self else { return false } - return true - } - - var isLoaded: Bool { - guard case .loaded = self else { return false } - return true - } - } - - static let scale: CGFloat = 0.3 - - private var store: ScenarioDisplayStore - - @State - private var status = Status.default - - @Environment(\.galleryDependency) - private var dependency - - init(store: ScenarioDisplayStore) { - self.store = store - } - - var body: some View { - VStack(spacing: 16) { - ZStack { - Color(.scenarioBackground) - - content() - .id(AnimationID.content) - } - .compositingGroup() - .transition(.opacity) - .animation(.spring(), value: status.isWaitForSnapshot) - .frame( - width: contentWidth, - height: store.snapshotLoader.device.size.height * Self.scale, - alignment: .topLeading - ) - .cornerRadius(12) - .shadow(color: Color(.black).opacity(0.25), radius: shadowRadius) - - Text(store.data.scenario.name.rawValue) - .font(.subheadline) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .background(Highlight(store.data.shouldHighlight)) - } - .frame(width: contentWidth + 16) - .padding(shadowRadius * 2) - .onVisibilityChanged( - in: CGRect(origin: .zero, size: dependency.context.screenSize), - perform: store.isVisible.send - ) - .onReceive(store.isVisible.removeDuplicates(by: ==)) { isVisible in - if isVisible { - self.store.loadImage(into: self.$status) - } - else { - self.store.cancellAll() - } - } - } -} - -private extension ScenarioDisplay { - enum AnimationID { - case content - } - - var shadowRadius: CGFloat { 4 } - - var contentWidth: CGFloat { - store.snapshotLoader.device.size.width * Self.scale - } - - func content() -> some View { - switch status { - case .default, .waitForSnapshot: - return AnyView(EmptyView()) - - case .loaded(let image): - return AnyView( - Image(uiImage: image) - .resizable() - .frame( - width: image.size.width * Self.scale, - height: image.size.height * Self.scale - ) - ) - - case .failed: - return AnyView( - Text("Could not load snapshot image") - .font(.caption) - .bold() - .lineLimit(nil) - .padding(.horizontal, 8) - ) - } - } -} - -private extension View { - func onVisibilityChanged(in bounds: CGRect, perform: @escaping (Bool) -> Void) -> some View { - background( - GeometryReader { geometry -> Color in - let vertical = geometry.size.height / 2 - let insets = UIEdgeInsets.only(top: vertical, bottom: vertical) - let isVisible = - bounds - .inset(by: insets) - .intersects(geometry.frame(in: .global)) - perform(isVisible) - return Color.clear - } - ) - } -} diff --git a/Sources/PlaybookUI/Internal/ScenarioDisplayList.swift b/Sources/PlaybookUI/Internal/ScenarioDisplayList.swift deleted file mode 100644 index d5ba812..0000000 --- a/Sources/PlaybookUI/Internal/ScenarioDisplayList.swift +++ /dev/null @@ -1,85 +0,0 @@ -import SwiftUI - -internal struct ScenarioDisplayList: View { - var data: SearchedListData - var safeAreaInsets: EdgeInsets - var onSelect: (SearchedData) -> Void - - @EnvironmentObject - private var store: GalleryStore - - @Environment(\.galleryDependency) - private var dependency - - private var serialDispatcher: SerialMainDispatcher - - init( - data: SearchedListData, - safeAreaInsets: EdgeInsets, - serialDispatcher: SerialMainDispatcher, - onSelect: @escaping (SearchedData) -> Void - ) { - self.data = data - self.safeAreaInsets = safeAreaInsets - self.serialDispatcher = serialDispatcher - self.onSelect = onSelect - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(symbol: .bookmarkFill) - .imageScale(.medium) - .font(.system(size: 20)) - .foregroundColor(Color(.primaryBlue)) - - Text(data.kind.rawValue) - .foregroundColor(Color(.label)) - .font(.system(size: 24)) - .bold() - .background(Highlight(data.shouldHighlight)) - - Spacer.zero - } - .padding(.horizontal, 24) - .padding(.leading, safeAreaInsets.leading) - .padding(.trailing, safeAreaInsets.trailing) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 0) { - ForEach(data.scenarios) { data in - Button( - action: { self.onSelect(data) }, - label: { - ScenarioDisplay( - store: ScenarioDisplayStore( - data: data, - snapshotLoader: self.store.snapshotLoader, - serialDispatcher: self.serialDispatcher, - scheduler: self.dependency.scheduler - ) - ) - } - ) - .buttonStyle(ScaleButtonStyle()) - } - } - .padding(.horizontal, 24) - .padding(.leading, safeAreaInsets.leading) - .padding(.trailing, safeAreaInsets.trailing) - } - - HorizontalSeparator() - } - .padding(.vertical, 8) - .onDisappear(perform: serialDispatcher.cancel) - } -} - -private struct ScaleButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.95 : 1) - .animation(.interactiveSpring(), value: configuration.isPressed) - } -} diff --git a/Sources/PlaybookUI/Internal/ScenarioDisplaySheet.swift b/Sources/PlaybookUI/Internal/ScenarioDisplaySheet.swift deleted file mode 100644 index d3a3373..0000000 --- a/Sources/PlaybookUI/Internal/ScenarioDisplaySheet.swift +++ /dev/null @@ -1,115 +0,0 @@ -import SwiftUI - -internal struct ScenarioDisplaySheet: View { - var data: SearchedData - var onClose: () -> Void - - @EnvironmentObject - private var store: GalleryStore - - @WeakReference - private var contentUIView: UIView? - - init( - data: SearchedData, - onClose: @escaping () -> Void - ) { - self.data = data - self.onClose = onClose - } - - var body: some View { - ZStack { - ScenarioContentView( - kind: data.kind, - scenario: data.scenario, - additionalSafeAreaInsets: .only(top: topBarHeight), - contentUIView: _contentUIView - ) - .edgesIgnoringSafeArea(.all) - .background( - Color(.scenarioBackground) - .edgesIgnoringSafeArea(.all) - ) - - VStack(spacing: 0) { - topBar() - - Divider() - .edgesIgnoringSafeArea(.all) - - Spacer.zero - } - } - .sheet(item: $store.shareItem) { item in - ImageSharingView(item: item) { self.store.shareItem = nil } - .edgesIgnoringSafeArea(.all) - } - .background( - Color(.scenarioBackground) - .edgesIgnoringSafeArea(.all) - ) - } -} - -private extension ScenarioDisplaySheet { - var topBarHeight: CGFloat { 44 } - - func topBar() -> some View { - HStack(spacing: 0) { - shareButton() - - Spacer(minLength: 24) - - Text(data.scenario.name.rawValue) - .bold() - .lineLimit(1) - - Spacer(minLength: 24) - - closeButton() - } - .padding(.horizontal, 24) - .frame(height: topBarHeight) - .background( - Blur(style: .systemMaterial) - .edgesIgnoringSafeArea(.all), - alignment: .topLeading - ) - } - - func shareButton() -> some View { - Button(action: share) { - Image(symbol: .squareAndArrowUp) - .imageScale(.large) - .font(.headline) - .foregroundColor(Color(.label)) - .frame(width: 30, height: 30) - } - } - - func closeButton() -> some View { - Button(action: onClose) { - ZStack { - Color.gray.opacity(0.2) - .clipShape(Circle()) - .frame(width: 30, height: 30) - - Image(symbol: .multiply) - .imageScale(.large) - .font(Font.subheadline.bold()) - .foregroundColor(.gray) - } - } - } - - func share() { - guard let uiView = contentUIView else { return } - - let image = UIGraphicsImageRenderer(bounds: uiView.bounds).image { _ in - uiView.drawHierarchy(in: uiView.bounds, afterScreenUpdates: true) - } - - store.shareItem = ImageSharingView.Item(image: image) - } -} diff --git a/Sources/PlaybookUI/Internal/ScenarioDisplayStore.swift b/Sources/PlaybookUI/Internal/ScenarioDisplayStore.swift deleted file mode 100644 index 2d43f65..0000000 --- a/Sources/PlaybookUI/Internal/ScenarioDisplayStore.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Combine -import SwiftUI - -internal final class ScenarioDisplayStore { - let data: SearchedData - let snapshotLoader: SnapshotLoaderProtocol - let serialDispatcher: SerialMainDispatcher - let isVisible = CurrentValueSubject(false) - - private let scheduler: SchedulerProtocol - - @Atomic - private var cancellables = Set() - - init( - data: SearchedData, - snapshotLoader: SnapshotLoaderProtocol, - serialDispatcher: SerialMainDispatcher, - scheduler: SchedulerProtocol = Scheduler() - ) { - self.data = data - self.snapshotLoader = snapshotLoader - self.serialDispatcher = serialDispatcher - self.scheduler = scheduler - } - - func loadImage(into status: Binding) { - guard !status.wrappedValue.isLoaded else { return } - - asyncLoadSnapshot() - .flatMap { [weak self] status -> AnyPublisher in - switch status { - case .loaded, .failed: - return Just(status) - .eraseToAnyPublisher() - - case .default, .waitForSnapshot: - guard let asyncTakeSnapshot = self?.asyncTakeSnapshot else { - return Empty().eraseToAnyPublisher() - } - - return Deferred(createPublisher: asyncTakeSnapshot) - .merge(with: Just(status)) - .eraseToAnyPublisher() - } - } - .assign(to: \.wrappedValue, on: status) - .store(in: &cancellables) - } - - func cancellAll() { - _cancellables.modify { $0.removeAll() } - } -} - -private extension ScenarioDisplayStore { - func asyncLoadSnapshot() -> Future { - func run() -> ScenarioDisplay.Status { - let result = snapshotLoader.loadImage(kind: data.kind, name: data.scenario.name) - - switch result { - case .success(let image?): - return .loaded(image: image) - - case .success(.none): - return .waitForSnapshot - - case .failure: - return .failed - } - } - - let scheduler = self.scheduler - - return Future { fulfill in - scheduler.schedule(on: .imageLoadQueue) { - fulfill(.success(run())) - } - } - } - - func asyncTakeSnapshot() -> Future { - Future { [weak self] fulfill in - guard let self = self else { - return fulfill(.success(.default)) - } - - self._cancellables.modify { cancellables in - let cancellable = self.serialDispatcher.dispatch { - self.snapshotLoader.takeSnapshot(for: self.data.scenario, kind: self.data.kind) { data in - let status: ScenarioDisplay.Status = UIImage(data: data).map { .loaded(image: $0) } ?? .failed - fulfill(.success(status)) - } - } - cancellables.insert(cancellable) - } - } - } -} - -private extension DispatchQueue { - static let imageLoadQueue = DispatchQueue( - label: "app.playbook-ui.ScenarioDisplay.imageLoadQueue", - qos: .userInitiated, - attributes: .concurrent, - autoreleaseFrequency: .workItem - ) -} diff --git a/Sources/PlaybookUI/Internal/ScenarioSearchStore.swift b/Sources/PlaybookUI/Internal/ScenarioSearchStore.swift deleted file mode 100644 index 7c89231..0000000 --- a/Sources/PlaybookUI/Internal/ScenarioSearchStore.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI - -internal class ScenarioSearchStore: ObservableObject { - @Published - private(set) var result = SearchResult(matchedCount: 0, data: []) - - let playbook: Playbook - - private(set) lazy var scenariosCount = playbook.stores.reduce(0) { $0 + $1.scenarios.count } - - var searchText: String? { - didSet { search(searchText) } - } - - init(playbook: Playbook) { - self.playbook = playbook - } - - @discardableResult - func start(with searchText: String? = nil) -> Self { - self.searchText = searchText - return self - } -} - -private extension ScenarioSearchStore { - func search(_ query: String?) { - let query = query?.lowercased() ?? "" - - func isMatched(_ string: String) -> Bool { - string.lowercased().contains(query) - } - - let data: [SearchedListData] - - if query.isEmpty { - data = playbook.stores.map { store in - SearchedListData( - kind: store.kind, - shouldHighlight: false, - scenarios: store.scenarios.map { scenario in - SearchedData( - scenario: scenario, - kind: store.kind, - shouldHighlight: false - ) - } - ) - } - } - else { - data = playbook.stores.compactMap { store in - if isMatched(store.kind.rawValue) { - return SearchedListData( - kind: store.kind, - shouldHighlight: true, - scenarios: store.scenarios.map { scenario in - SearchedData( - scenario: scenario, - kind: store.kind, - shouldHighlight: isMatched(scenario.name.rawValue) - ) - } - ) - } - else { - let data = SearchedListData( - kind: store.kind, - shouldHighlight: false, - scenarios: store.scenarios.compactMap { scenario in - guard isMatched(scenario.name.rawValue) else { return nil } - - return SearchedData( - scenario: scenario, - kind: store.kind, - shouldHighlight: true - ) - } - ) - return data.scenarios.isEmpty ? nil : data - } - } - } - - return result = SearchResult( - matchedCount: data.reduce(0) { $0 + $1.scenarios.count }, - data: data - ) - } -} diff --git a/Sources/PlaybookUI/Internal/ScenarioSearchTree.swift b/Sources/PlaybookUI/Internal/ScenarioSearchTree.swift deleted file mode 100644 index cbfce89..0000000 --- a/Sources/PlaybookUI/Internal/ScenarioSearchTree.swift +++ /dev/null @@ -1,406 +0,0 @@ -import SwiftUI - -internal struct ScenarioSearchTree: View { - @ViewBuilder - var body: some View { - #if swift(>=5.3) - if #available(iOS 14.0, *) { - ScenarioSearchTreeIOS14() - } - else { - ScenarioSearchTreeIOS13() - } - #else - ScenarioSearchTreeIOS13() - #endif - } -} - -#if swift(>=5.3) -@available(iOS 14.0, *) -internal struct ScenarioSearchTreeIOS14: View { - @EnvironmentObject - var store: CatalogStore - - var body: some View { - VStack(spacing: .zero) { - searchBar() - - if store.result.data.isEmpty { - emptyContent() - } - else { - ScrollView { - LazyVStack(spacing: .zero) { - ForEach(store.result.data, id: \.kind) { data in - let isOpened = currentOpenedKindsBinding().wrappedValue.contains(data.kind) - - kindRow( - data: data, - isOpened: isOpened - ) - - if isOpened { - ForEach(data.scenarios, id: \.id) { data in - scenarioRow( - data: data, - isSelected: data.id == store.selectedScenario?.id - ) - } - } - } - } - } - } - } - .background( - Color(.secondaryBackground).ignoresSafeArea() - ) - } -} - -@available(iOS 14.0, *) -private extension ScenarioSearchTreeIOS14 { - func searchTextBinding() -> Binding { - Binding( - get: { store.searchText }, - set: { newValue in - let isEmpty = newValue.map { $0.isEmpty } ?? true - store.openedSearchingKinds = isEmpty ? nil : Set(store.result.data.map { $0.kind }) - store.searchText = newValue - } - ) - } - - func currentOpenedKindsBinding() -> Binding> { - Binding($store.openedSearchingKinds) ?? $store.openedKinds - } - - func kindRow(data: SearchedListData, isOpened: Bool) -> some View { - Button( - action: { - if isOpened { - currentOpenedKindsBinding().wrappedValue.remove(data.kind) - } - else { - currentOpenedKindsBinding().wrappedValue.insert(data.kind) - } - }, - label: { - VStack(spacing: .zero) { - HStack(spacing: 8) { - Image(symbol: .chevronRight) - .imageScale(.small) - .foregroundColor(Color(.label)) - .rotationEffect(.radians(isOpened ? .pi / 2 : 0)) - - Image(symbol: .bookmarkFill) - .imageScale(.medium) - .foregroundColor(Color(.primaryBlue)) - - Text(data.kind.rawValue) - .bold() - .font(.system(size: 20)) - .lineSpacing(4) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(Color(.label)) - .background(Highlight(data.shouldHighlight)) - - Spacer(minLength: 16) - } - .padding(.vertical, 24) - - HorizontalSeparator() - } - .padding(.leading, 16) - } - ) - } - - func scenarioRow(data: SearchedData, isSelected: Bool) -> some View { - Button( - action: { - store.selectedScenario = data - }, - label: { - VStack(spacing: .zero) { - HStack(spacing: 8) { - Image(symbol: .circleFill) - .font(.system(size: 10)) - .foregroundColor(Color(isSelected ? .primaryBlue : .tertiarySystemFill)) - - Text(data.scenario.name.rawValue) - .font(.subheadline) - .bold() - .lineLimit(nil) - .lineSpacing(4) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(Color(.label)) - .background(Highlight(data.shouldHighlight)) - - Spacer(minLength: 16) - } - .padding(.vertical, 16) - - HorizontalSeparator() - } - .padding(.leading, 56) - } - ) - } - - func emptyContent() -> some View { - VStack(spacing: .zero) { - Text("This filter resulted in 0 results") - .foregroundColor(Color(.label)) - .font(.body) - .bold() - .lineLimit(nil) - .padding(24) - .padding(.top, 24) - - Spacer.zero - } - } - - func searchBar() -> some View { - VStack(spacing: .zero) { - SearchBar(text: searchTextBinding(), height: 44) - - Counter( - numerator: store.result.matchedCount, - denominator: store.scenariosCount - ) - - HorizontalSeparator() - .padding(.top, 8) - } - } -} -#endif - -internal struct ScenarioSearchTreeIOS13: View { - @EnvironmentObject - private var store: CatalogStore - - var body: some View { - VStack(spacing: 0) { - searchBar() - - if store.result.data.isEmpty { - emptyContent() - } - else { - TableView( - snapshot: snapshot(), - configureUIView: configureTableView, - row: row, - onSelect: onSelect - ) - .edgesIgnoringSafeArea(.bottom) - } - } - .background( - Color(.secondaryBackground) - .edgesIgnoringSafeArea(.all) - ) - } -} - -private extension ScenarioSearchTreeIOS13 { - struct Section: Hashable { - var data: SearchedListData - - func hash(into hasher: inout Hasher) { - hasher.combine(data.kind) - } - - static func == (lhs: Section, rhs: Section) -> Bool { - lhs.data.kind == rhs.data.kind && lhs.data.shouldHighlight == rhs.data.shouldHighlight - } - } - - enum Row: Hashable { - case kind(data: SearchedListData, isOpened: Bool) - case scenario(data: SearchedData, isSelected: Bool) - - func hash(into hasher: inout Hasher) { - switch self { - case .kind(let data, isOpened: _): - hasher.combine(data.kind) - - case .scenario(let data, isSelected: _): - hasher.combine(data.id) - } - } - - static func == (lhs: Row, rhs: Row) -> Bool { - switch (lhs, rhs) { - case (.kind(let lData, let lIsOpened), .kind(let rData, let rIsOpened)): - return lData.kind == rData.kind - && lData.shouldHighlight == rData.shouldHighlight - && lIsOpened == rIsOpened - - case (.scenario(let lData, let lIsSelected), .scenario(let rData, let rIsSelected)): - return lData.kind == rData.kind - && lData.scenario.name == rData.scenario.name - && lData.shouldHighlight == rData.shouldHighlight - && lIsSelected == rIsSelected - - default: - return false - } - } - } - - func currentOpenedKindsBinding() -> Binding> { - Binding($store.openedSearchingKinds) ?? $store.openedKinds - } - - func searchTextBinding() -> Binding { - Binding( - get: { self.store.searchText }, - set: { newValue in - let isEmpty = newValue.map { $0.isEmpty } ?? true - self.store.openedSearchingKinds = isEmpty ? nil : Set(self.store.result.data.map { $0.kind }) - self.store.searchText = newValue - } - ) - } - - func searchBar() -> some View { - VStack(spacing: .zero) { - SearchBar(text: searchTextBinding(), height: 44) - - Counter( - numerator: store.result.matchedCount, - denominator: store.scenariosCount - ) - - HorizontalSeparator() - .padding(.top, 8) - } - } - - func row(with row: Row) -> some View { - switch row { - case .kind(let data, let isOpened): - return AnyView(kindRow(data: data, isOpened: isOpened)) - - case .scenario(let data, let isSelected): - return AnyView(scenarioRow(data: data, isSelected: isSelected)) - } - } - - func kindRow(data: SearchedListData, isOpened: Bool) -> some View { - VStack(spacing: 0) { - HStack(spacing: 8) { - Image(symbol: .chevronRight) - .imageScale(.small) - .foregroundColor(Color(.label)) - .rotationEffect(.radians(isOpened ? .pi / 2 : 0)) - - Image(symbol: .bookmarkFill) - .imageScale(.medium) - .foregroundColor(Color(.primaryBlue)) - - Text(data.kind.rawValue) - .bold() - .font(.system(size: 20)) - .lineSpacing(4) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(Color(.label)) - .background(Highlight(data.shouldHighlight)) - - Spacer(minLength: 16) - } - .padding(.vertical, 24) - - HorizontalSeparator() - } - .padding(.leading, 16) - } - - func scenarioRow(data: SearchedData, isSelected: Bool) -> some View { - VStack(spacing: 0) { - HStack(spacing: 8) { - Image(symbol: .circleFill) - .font(.system(size: 10)) - .foregroundColor(Color(isSelected ? .primaryBlue : .tertiarySystemFill)) - - Text(data.scenario.name.rawValue) - .font(.subheadline) - .bold() - .lineLimit(nil) - .lineSpacing(4) - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(Color(.label)) - .background(Highlight(data.shouldHighlight)) - - Spacer(minLength: 16) - } - .padding(.vertical, 16) - - HorizontalSeparator() - } - .padding(.leading, 56) - } - - func emptyContent() -> some View { - VStack(spacing: 0) { - Text("This filter resulted in 0 results") - .foregroundColor(Color(.label)) - .font(.body) - .bold() - .lineLimit(nil) - .padding(24) - - Spacer.zero - } - .padding(.top, 24) - } - - func snapshot() -> NSDiffableDataSourceSnapshot { - var snapshot = NSDiffableDataSourceSnapshot() - - guard !store.result.data.isEmpty else { - return snapshot - } - - let sections = store.result.data.map(Section.init) - snapshot.appendSections(sections) - - for section in sections { - let isOpened = currentOpenedKindsBinding().wrappedValue.contains(section.data.kind) - let scenarios = isOpened ? section.data.scenarios.map { Row.scenario(data: $0, isSelected: $0.id == store.selectedScenario?.id) } : [] - snapshot.appendItems([.kind(data: section.data, isOpened: isOpened)] + scenarios, toSection: section) - } - - return snapshot - } - - func onSelect(_ row: Row) { - switch row { - case .kind(let data, let isOpened): - if isOpened { - currentOpenedKindsBinding().wrappedValue.remove(data.kind) - } - else { - currentOpenedKindsBinding().wrappedValue.insert(data.kind) - } - - case .scenario(let data, isSelected: _): - store.selectedScenario = data - } - } - - func configureTableView(_ tableView: UITableView) { - tableView.estimatedRowHeight = 70 - tableView.contentInset.bottom = 44 - tableView.separatorStyle = .none - tableView.keyboardDismissMode = .onDrag - tableView.insetsContentViewsToSafeArea = false - } -} diff --git a/Sources/PlaybookUI/Internal/Scheduler.swift b/Sources/PlaybookUI/Internal/Scheduler.swift deleted file mode 100644 index 43df243..0000000 --- a/Sources/PlaybookUI/Internal/Scheduler.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -internal protocol SchedulerProtocol { - func schedule(on: DispatchQueue, action: @escaping () -> Void) - func schedule(on: DispatchQueue, after interval: TimeInterval, action: @escaping () -> Void) -} - -internal struct Scheduler: SchedulerProtocol { - func schedule(on queue: DispatchQueue, action: @escaping () -> Void) { - queue.async(execute: action) - } - - func schedule(on queue: DispatchQueue, after interval: TimeInterval = 0, action: @escaping () -> Void) { - queue.asyncAfter(deadline: .now() + interval, execute: action) - } -} diff --git a/Sources/PlaybookUI/Internal/SearchBar.swift b/Sources/PlaybookUI/Internal/SearchBar.swift deleted file mode 100644 index 1cb9b3a..0000000 --- a/Sources/PlaybookUI/Internal/SearchBar.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI -import UIKit - -internal struct SearchBar: View { - @Binding - var text: String? - var height: CGFloat - - var body: some View { - SearchBarWrapper(text: $text, placeholder: "Search") { searchBar in - let backgroundImage = UIColor.tertiarySystemFill.circleImage(length: self.height) - searchBar.setSearchFieldBackgroundImage(backgroundImage, for: .normal) - } - .animation(nil) - .accentColor(Color(.primaryBlue)) - .frame(height: height) - .padding(.top, 16) - .padding(.horizontal, 8) - } -} - -private struct SearchBarWrapper: UIViewRepresentable { - @Binding - var text: String? - - var placeholder: String? - var updateUIView: ((UISearchBar) -> Void)? - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeUIView(context: Context) -> UISearchBar { - let searchBar = UISearchBar() - searchBar.enablesReturnKeyAutomatically = false - searchBar.backgroundImage = UIImage() - searchBar.setPositionAdjustment(UIOffset(horizontal: 8, vertical: 0), for: .search) - searchBar.setPositionAdjustment(UIOffset(horizontal: -8, vertical: 0), for: .clear) - return searchBar - } - - func updateUIView(_ uiView: UISearchBar, context: Context) { - uiView.text = text - uiView.placeholder = placeholder - uiView.delegate = context.coordinator - updateUIView?(uiView) - } -} - -private extension SearchBarWrapper { - final class Coordinator: NSObject, UISearchBarDelegate { - let base: SearchBarWrapper - - init(_ base: SearchBarWrapper) { - self.base = base - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - base.text = searchText - } - - func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - searchBar.setShowsCancelButton(true, animated: true) - } - - func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { - searchBar.setShowsCancelButton(false, animated: true) - } - - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - } - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - } - } -} diff --git a/Sources/PlaybookUI/Internal/SearchResult.swift b/Sources/PlaybookUI/Internal/SearchResult.swift deleted file mode 100644 index 5971f9d..0000000 --- a/Sources/PlaybookUI/Internal/SearchResult.swift +++ /dev/null @@ -1,4 +0,0 @@ -internal struct SearchResult { - var matchedCount: Int - var data: [SearchedListData] -} diff --git a/Sources/PlaybookUI/Internal/SearchedData.swift b/Sources/PlaybookUI/Internal/SearchedData.swift deleted file mode 100644 index 6cdadde..0000000 --- a/Sources/PlaybookUI/Internal/SearchedData.swift +++ /dev/null @@ -1,14 +0,0 @@ -internal struct SearchedData: Identifiable { - struct ID: Hashable { - var kind: ScenarioKind - var name: ScenarioName - } - - var scenario: Scenario - var kind: ScenarioKind - var shouldHighlight: Bool - - var id: ID { - ID(kind: kind, name: scenario.name) - } -} diff --git a/Sources/PlaybookUI/Internal/SearchedListData.swift b/Sources/PlaybookUI/Internal/SearchedListData.swift deleted file mode 100644 index 810b40a..0000000 --- a/Sources/PlaybookUI/Internal/SearchedListData.swift +++ /dev/null @@ -1,5 +0,0 @@ -internal struct SearchedListData { - var kind: ScenarioKind - var shouldHighlight: Bool - var scenarios: [SearchedData] -} diff --git a/Sources/PlaybookUI/Internal/SerialMainDispatcher.swift b/Sources/PlaybookUI/Internal/SerialMainDispatcher.swift deleted file mode 100644 index 5125e0c..0000000 --- a/Sources/PlaybookUI/Internal/SerialMainDispatcher.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Combine -import Foundation - -internal final class SerialMainDispatcher { - private struct Resource { - var queue = ContiguousArray() - var cancellable: AnyCancellable? - } - - private let interval: TimeInterval - - @Atomic - private var resource = Resource() - - private var scheduler: SchedulerProtocol - - init(interval: TimeInterval, scheduler: SchedulerProtocol) { - self.interval = interval - self.scheduler = scheduler - } - - func dispatch(block: @escaping () -> Void) -> AnyCancellable { - let workItem = DispatchWorkItem(block: block) - let shouldStart: Bool = _resource.modify { resource in - let isEmpty = resource.queue.isEmpty - resource.queue.append(workItem) - return isEmpty - } - - if shouldStart { - executeNext() - } - - return AnyCancellable(workItem.cancel) - } - - func cancel() { - resource = Resource() - } -} - -private extension SerialMainDispatcher { - func executeNext() { - let nextWorkItem: DispatchWorkItem? = _resource.modify { resource in - guard let workItem = resource.queue.first else { return nil } - - if workItem.isCancelled { - resource.queue.removeFirst() - return workItem - } - else { - resource.cancellable = AnyCancellable(workItem.cancel) - return workItem - } - } - - guard let workItem = nextWorkItem else { - return - } - - guard !workItem.isCancelled else { - return executeNext() - } - - scheduler.schedule(on: .main, after: interval) { [weak self] in - guard let self = self else { return } - - if !workItem.isCancelled { - workItem.perform() - } - - self._resource.modify { - guard !$0.queue.isEmpty else { return } - $0.queue.removeFirst() - } - - self.executeNext() - } - } -} diff --git a/Sources/PlaybookUI/Internal/SnapshotLoader.swift b/Sources/PlaybookUI/Internal/SnapshotLoader.swift deleted file mode 100644 index 81a5681..0000000 --- a/Sources/PlaybookUI/Internal/SnapshotLoader.swift +++ /dev/null @@ -1,86 +0,0 @@ -import SwiftUI - -internal protocol SnapshotLoaderProtocol { - var device: SnapshotDevice { get } - - func takeSnapshot(for scenario: Scenario, kind: ScenarioKind, completion: ((Data) -> Void)?) - func loadImage(kind: ScenarioKind, name: ScenarioName) -> Result - func clean() -} - -internal final class SnapshotLoader: SnapshotLoaderProtocol { - let name: String - let baseDirectoryURL: URL - let format: SnapshotSupport.ImageFormat - let device: SnapshotDevice - - var directoryURL: URL { - baseDirectoryURL.appendingPathComponent(name, isDirectory: true) - } - - init( - name: String, - baseDirectoryURL: URL, - format: SnapshotSupport.ImageFormat, - device: SnapshotDevice - ) { - self.baseDirectoryURL = baseDirectoryURL - self.name = name - self.format = format - self.device = device - } - - func takeSnapshot(for scenario: Scenario, kind: ScenarioKind, completion: ((Data) -> Void)?) { - SnapshotSupport.data( - for: scenario, - on: device, - format: format, - scale: 1, - handler: { [weak self] data in - self?.storeImage(data: data, kind: kind, name: scenario.name) - completion?(data) - } - ) - } - - func loadImage(kind: ScenarioKind, name: ScenarioName) -> Result { - Result { - let fileURL = self.fileURL(kind: kind, name: name) - - if FileManager.default.fileExists(atPath: fileURL.path) { - let data = try Data(contentsOf: fileURL) - return UIImage(data: data) - } - else { - return nil - } - } - } - - func clean() { - if FileManager.default.fileExists(atPath: directoryURL.path) { - try? FileManager.default.removeItem(at: directoryURL) - } - } -} - -private extension SnapshotLoader { - func fileURL(kind: ScenarioKind, name: ScenarioName) -> URL { - directoryURL - .appendingPathComponent(kind.rawValue, isDirectory: true) - .appendingPathComponent(name.rawValue) - .appendingPathExtension(format.fileExtension) - } - - func storeImage(data: Data, kind: ScenarioKind, name: ScenarioName) { - let fileManager = FileManager.default - let fileURL = self.fileURL(kind: kind, name: name) - let kindDirectoryURL = fileURL.deletingLastPathComponent() - - if !fileManager.fileExists(atPath: kindDirectoryURL.path) { - try? fileManager.createDirectory(at: kindDirectoryURL, withIntermediateDirectories: true) - } - - try? data.write(to: fileURL) - } -} diff --git a/Sources/PlaybookUI/Internal/State/CatalogState.swift b/Sources/PlaybookUI/Internal/State/CatalogState.swift new file mode 100644 index 0000000..b8f06fc --- /dev/null +++ b/Sources/PlaybookUI/Internal/State/CatalogState.swift @@ -0,0 +1,49 @@ +import Playbook +import SwiftUI + +@MainActor +internal final class CatalogState: ObservableObject { + @Published + var selected: SelectData? + @Published + var isSearchPainCollapsed = true + @Published + var colorScheme: ColorScheme? + @Published + var expandedKinds = Set() + @Published + var searchingKinds: Set? + + var currentExpandedKinds: Set { + get { searchingKinds ?? expandedKinds } + set { + if searchingKinds != nil { + searchingKinds = newValue + } + else { + expandedKinds = newValue + } + } + } + + func selectInitial(searchResult: SearchResult) { + guard selected == nil else { + return + } + + let scenario = searchResult.kinds.first?.scenarios.first + selected = scenario.map { scenario in + SelectData( + kind: scenario.kind, + scenario: scenario.scenario + ) + } + } + + func updateSearchingKinds(query: String, searchResult: SearchResult) { + searchingKinds = + query.isEmpty + ? nil + : Set(searchResult.kinds.map(\.kind)) + } +} diff --git a/Sources/PlaybookUI/Internal/State/GalleryState.swift b/Sources/PlaybookUI/Internal/State/GalleryState.swift new file mode 100644 index 0000000..79ed45a --- /dev/null +++ b/Sources/PlaybookUI/Internal/State/GalleryState.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@MainActor +internal final class GalleryState: ObservableObject { + @Published + var selected: SelectData? + @Published + var colorScheme: ColorScheme? + + func clearImageCache() { + let imageCache = ImageCache() + imageCache.clear() + } +} diff --git a/Sources/PlaybookUI/Internal/State/SearchState.swift b/Sources/PlaybookUI/Internal/State/SearchState.swift new file mode 100644 index 0000000..f5d7b32 --- /dev/null +++ b/Sources/PlaybookUI/Internal/State/SearchState.swift @@ -0,0 +1,88 @@ +import Playbook +import SwiftUI + +@MainActor +internal final class SearchState: ObservableObject { + private let playbook: Playbook + + @Published + var query = "" { + didSet { search(query: query) } + } + + @Published + private(set) var result = SearchResult(count: 0, total: 0, kinds: []) + + init(playbook: Playbook) { + self.playbook = playbook + search(query: query) + } +} + +private extension SearchState { + func search(query: String) { + let query = query.lowercased() + + func matchedRange(_ string: String) -> Range? { + string.lowercased().range(of: query) + } + + let kinds: [SearchedKindData] = + if query.isEmpty { + playbook.stores.map { store in + SearchedKindData( + kind: store.kind, + highlightRange: nil, + scenarios: store.scenarios.map { scenario in + SearchedData( + kind: store.kind, + scenario: scenario, + highlightRange: nil + ) + } + ) + } + } + else { + playbook.stores.compactMap { store in + if let range = matchedRange(store.kind.rawValue) { + return SearchedKindData( + kind: store.kind, + highlightRange: range, + scenarios: store.scenarios.map { scenario in + SearchedData( + kind: store.kind, + scenario: scenario, + highlightRange: matchedRange(scenario.name.rawValue) + ) + } + ) + } + else { + let data = SearchedKindData( + kind: store.kind, + highlightRange: nil, + scenarios: store.scenarios.compactMap { scenario in + guard let range = matchedRange(scenario.name.rawValue) else { + return nil + } + + return SearchedData( + kind: store.kind, + scenario: scenario, + highlightRange: range + ) + } + ) + return data.scenarios.isEmpty ? nil : data + } + } + } + + result = SearchResult( + count: kinds.reduce(0) { $0 + $1.scenarios.count }, + total: playbook.stores.reduce(0) { $0 + $1.scenarios.count }, + kinds: kinds + ) + } +} diff --git a/Sources/PlaybookUI/Internal/State/ShareState.swift b/Sources/PlaybookUI/Internal/State/ShareState.swift new file mode 100644 index 0000000..18240b3 --- /dev/null +++ b/Sources/PlaybookUI/Internal/State/ShareState.swift @@ -0,0 +1,37 @@ +import Playbook +import SwiftUI + +@MainActor +internal final class ShareState: ObservableObject { + weak var scenarioViewController: ScenarioViewController? + + func shareSnapshot() { + guard let scenarioViewController else { + return + } + + let bounds = scenarioViewController.view.bounds + let image = UIGraphicsImageRenderer(bounds: bounds).image { _ in + scenarioViewController.view.drawHierarchy(in: bounds, afterScreenUpdates: true) + } + let activityViewController = UIActivityViewController( + activityItems: [image], + applicationActivities: nil + ) + + if !Bundle.main.hasPhotoLibraryAddUsageDescription { + activityViewController.excludedActivityTypes = [.saveToCameraRoll] + } + + activityViewController.popoverPresentationController?.sourceView = scenarioViewController.view + activityViewController.popoverPresentationController?.permittedArrowDirections = [] + scenarioViewController.present(activityViewController, animated: true) + } +} + +private extension Bundle { + var hasPhotoLibraryAddUsageDescription: Bool { + let usage = object(forInfoDictionaryKey: "NSPhotoLibraryAddUsageDescription") as? String + return usage.map { !$0.isEmpty } ?? false + } +} diff --git a/Sources/PlaybookUI/Internal/TableView.swift b/Sources/PlaybookUI/Internal/TableView.swift deleted file mode 100644 index ef4148e..0000000 --- a/Sources/PlaybookUI/Internal/TableView.swift +++ /dev/null @@ -1,137 +0,0 @@ -import SwiftUI - -internal struct TableView: UIViewRepresentable { - var animated: Bool - var snapshot: NSDiffableDataSourceSnapshot - var configureUIView: ((UITableView) -> Void)? - var row: (RowData) -> Row - var onSelect: ((RowData) -> Void)? - - init( - animated: Bool = true, - snapshot: NSDiffableDataSourceSnapshot, - configureUIView: ((UITableView) -> Void)?, - row: @escaping (RowData) -> Row, - onSelect: ((RowData) -> Void)? = nil - ) { - self.animated = animated - self.snapshot = snapshot - self.configureUIView = configureUIView - self.row = row - self.onSelect = onSelect - } - - func makeCoordinator() -> Coordinator { - Coordinator(base: self) - } - - func makeUIView(context: Context) -> UIViewType { - let tableView = UIViewType() - let dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, _ in - context.coordinator.dequeueCell(for: tableView, indexPath: indexPath) - } - - tableView.backgroundColor = .clear - dataSource.defaultRowAnimation = .fade - context.coordinator.dataSource = dataSource - configureUIView?(tableView) - - return tableView - } - - func updateUIView(_ tableView: UIViewType, context: Context) { - context.coordinator.base = self - tableView.dataSource = context.coordinator.dataSource - tableView.delegate = context.coordinator - tableView.allowsSelection = onSelect != nil - tableView.readyForUpdate = nil - - if tableView.window != nil { - let dataSource = context.coordinator.dataSource - let isEmptyBefore = dataSource?.snapshot().sectionIdentifiers.isEmpty ?? false - dataSource?.apply(snapshot, animatingDifferences: animated && !isEmptyBefore) - } - else { - tableView.readyForUpdate = { [weak tableView] in - guard let tableView = tableView else { return } - self.updateUIView(tableView, context: context) - } - } - } -} - -internal extension TableView { - final class UIViewType: UITableView { - var readyForUpdate: (() -> Void)? - - override func layoutSubviews() { - if window != nil { - super.layoutSubviews() - readyForUpdate?() - readyForUpdate = nil - } - } - } - - final class Coordinator: NSObject, UITableViewDelegate { - var base: TableView - var dataSource: UITableViewDiffableDataSource? - - init(base: TableView) { - self.base = base - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let onSelect = base.onSelect else { return } - - let sectionData = base.snapshot.sectionIdentifiers[indexPath.section] - let rowData = base.snapshot.itemIdentifiers(inSection: sectionData)[indexPath.row] - - onSelect(rowData) - tableView.deselectRow(at: indexPath, animated: false) - } - - func dequeueCell(for tableView: UITableView, indexPath: IndexPath) -> Cell { - let sectionData = base.snapshot.sectionIdentifiers[indexPath.section] - let rowData = base.snapshot.itemIdentifiers(inSection: sectionData)[indexPath.row] - let reuseIdentifier = rowData.hashValue.description - - guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) as? Cell else { - tableView.register(Cell.self, forCellReuseIdentifier: reuseIdentifier) - return dequeueCell(for: tableView, indexPath: indexPath) - } - - cell.hostingController.rootView = base.row(rowData) - return cell - } - } - - final class Cell: UITableViewCell { - let hostingController = UIHostingController(rootView: nil) - - override init(style: CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - layout() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - private func layout() { - hostingController.view.backgroundColor = .clear - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - backgroundColor = .clear - contentView.backgroundColor = .clear - contentView.addSubview(hostingController.view) - - NSLayoutConstraint.activate([ - contentView.topAnchor.constraint(equalTo: hostingController.view.topAnchor), - contentView.bottomAnchor.constraint(equalTo: hostingController.view.bottomAnchor), - contentView.leadingAnchor.constraint(equalTo: hostingController.view.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: hostingController.view.trailingAnchor), - ]) - } - } -} diff --git a/Sources/PlaybookUI/Internal/Utilities/Image.swift b/Sources/PlaybookUI/Internal/Utilities/Image.swift new file mode 100644 index 0000000..d2fe20e --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/Image.swift @@ -0,0 +1,21 @@ +import SwiftUI + +internal extension Image { + enum SFSymbols: String { + case xmark + case xmarkCircleFill = "xmark.circle.fill" + case ellipsisCircle = "ellipsis.circle" + case magnifyingglass + case sunMax = "sun.max" + case moon + case docTextMagnifyingglass = "doc.text.magnifyingglass" + case chevronRight = "chevron.right" + case circleFill = "circle.fill" + case sidebarLeft = "sidebar.left" + case photo + } + + init(symbol: SFSymbols) { + self.init(systemName: symbol.rawValue) + } +} diff --git a/Sources/PlaybookUI/Internal/Utilities/ImageCache.swift b/Sources/PlaybookUI/Internal/Utilities/ImageCache.swift new file mode 100644 index 0000000..f2943af --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/ImageCache.swift @@ -0,0 +1,89 @@ +import Playbook +import SwiftUI + +internal struct ImageCache { + private let fileManager = FileManager.default + + func data(for source: ImageSource) -> Data? { + let url = fileURL(for: source) + + do { + if fileManager.fileExists(atPath: url.path) { + return try Data(contentsOf: url) + } + else { + return nil + } + } + catch { + debugLog(error: error, description: "Failed to read cache data.") + remove(at: url) + return nil + } + } + + func create(file: Data, for source: ImageSource) { + do { + let fileURL = fileURL(for: source) + let directoryURL = fileURL.deletingPathExtension() + + if !fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } + + try file.write(to: fileURL) + } + catch { + debugLog(error: error, description: "Failed to create cache data.") + } + } + + func clear() { + let url = baseDirectoryURL() + remove(at: url) + } +} + +private extension ImageCache { + static let normalizationCharacters = CharacterSet(charactersIn: ".:/") + .union(.whitespacesAndNewlines) + .union(.illegalCharacters) + .union(.controlCharacters) + + func baseDirectoryURL() -> URL { + fileManager + .urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("app.playbook-ui", isDirectory: true) + } + + func fileURL(for source: ImageSource) -> URL { + baseDirectoryURL() + .appendingPathComponent("images", isDirectory: true) + .appendingPathComponent(source.size.width.description, isDirectory: true) + .appendingPathComponent(source.size.height.description, isDirectory: true) + .appendingPathComponent(source.scale.description, isDirectory: true) + .appendingPathComponent("\(source.colorScheme)", isDirectory: true) + .appendingPathComponent(normalize(source.kind.rawValue), isDirectory: true) + .appendingPathComponent(normalize(source.scenario.name.rawValue)) + .appendingPathExtension(SnapshotSupport.ImageFormat.png.fileExtension) + } + + func remove(at url: URL) { + do { + try fileManager.removeItem(at: url) + } + catch { + debugLog(error: error, description: "Failed to clear cache data.") + } + } + + func normalize(_ string: String) -> String { + string.components(separatedBy: Self.normalizationCharacters).joined(separator: "_") + } + + func debugLog(error: @autoclosure () -> Error, description: @autoclosure () -> String) { + #if DEBUG + print("[Playbook]", description(), error()) + #endif + } +} diff --git a/Sources/PlaybookUI/Internal/Utilities/ImageLoader.swift b/Sources/PlaybookUI/Internal/Utilities/ImageLoader.swift new file mode 100644 index 0000000..febe7ad --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/ImageLoader.swift @@ -0,0 +1,135 @@ +import Playbook +import SwiftUI + +@MainActor +internal final class ImageLoader: ObservableObject { + private let imageCache = ImageCache() + private var queue = ContiguousArray() + private var sequentiallyProcessedCount = 0 + + func loadImage(for source: ImageSource) async -> UIImage? { + if let data = imageCache.data(for: source) { + return UIImage(data: data) + } + else { + let item = QueueItem(source: source) + queue.append(item) + + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + item.continuation = continuation + + if queue.count == 1 { + start() + } + } + } onCancel: { + item.isCancelled = true + item.continuation?.resume(returning: nil) + item.continuation = nil + } + } + } +} + +private extension ImageLoader { + final class QueueItem { + let source: ImageSource + var continuation: CheckedContinuation? + var isCancelled = false + + init(source: ImageSource) { + self.source = source + } + } + + func start() { + guard let item = queue.first else { + return sequentiallyProcessedCount = 0 + } + + guard !item.isCancelled else { + queue.removeFirst() + return startNext() + } + + sequentiallyProcessedCount += 1 + + // Perform snapshotting in `default` mode to avoid blocking UI tracking events. + RunLoop.main.perform(inModes: [.default]) { [weak self] in + guard let self else { + return + } + + guard !item.isCancelled else { + queue.removeFirst() + return startNext() + } + + SnapshotSupport.data( + for: item.source.scenario, + on: SnapshotDevice( + name: item.source.scenario.name.rawValue, + size: item.source.size, + traitCollection: UITraitCollection( + traitsFrom: [ + UITraitCollection(userInterfaceStyle: item.source.colorScheme.userInterfaceStyle), + UITraitCollection(horizontalSizeClass: .compact), + UITraitCollection(verticalSizeClass: .regular), + UITraitCollection(layoutDirection: .leftToRight), + UITraitCollection(preferredContentSizeCategory: .large), + ] + ) + ), + format: .png, + scale: item.source.scale + ) { [weak self] data in + guard let self else { + return + } + + let image = UIImage(data: data) + item.continuation?.resume(returning: image) + item.continuation = nil + imageCache.create(file: data, for: item.source) + queue.removeFirst() + startNext() + } + } + } + + func startNext() { + guard !queue.isEmpty else { + return + } + + if sequentiallyProcessedCount < 4 { + start() + } + else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self else { + return + } + + sequentiallyProcessedCount = 0 + start() + } + } + } +} + +private extension ColorScheme { + var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .light: + return .light + + case .dark: + return .dark + + @unknown default: + return .unspecified + } + } +} diff --git a/Sources/PlaybookUI/Internal/Utilities/ImageSource.swift b/Sources/PlaybookUI/Internal/Utilities/ImageSource.swift new file mode 100644 index 0000000..9263bf8 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/ImageSource.swift @@ -0,0 +1,10 @@ +import Playbook +import SwiftUI + +struct ImageSource { + let scenario: Scenario + let kind: ScenarioKind + let size: CGSize + let scale: CGFloat + let colorScheme: ColorScheme +} diff --git a/Sources/PlaybookUI/Internal/Utilities/Spacer.swift b/Sources/PlaybookUI/Internal/Utilities/Spacer.swift new file mode 100644 index 0000000..dd3590d --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/Spacer.swift @@ -0,0 +1,11 @@ +import SwiftUI + +internal extension Spacer { + static var zero: Spacer { + Spacer(minLength: 0) + } + + static func fixed(length: CGFloat) -> some View { + Spacer(minLength: length).fixedSize() + } +} diff --git a/Sources/PlaybookUI/Internal/Utilities/UIColor.swift b/Sources/PlaybookUI/Internal/Utilities/UIColor.swift new file mode 100644 index 0000000..3310ad8 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/UIColor.swift @@ -0,0 +1,21 @@ +import UIKit + +internal extension UIColor { + static let primaryBlue = UIColor(hex: 0x048DFF) + static let highlight = UIColor.systemYellow + static let translucentFill = UIColor.secondarySystemFill + static let primaryBackground = UIColor { traitCollection in + traitCollection.userInterfaceStyle == .light ? .white : .black + } + static let secondaryBackground = UIColor.secondarySystemBackground +} + +private extension UIColor { + convenience init(hex: Int) { + let red = CGFloat((hex & 0xFF0000) >> 16) / 255 + let green = CGFloat((hex & 0x00FF00) >> 8) / 255 + let blue = CGFloat((hex & 0x0000FF) >> 0) / 255 + + self.init(red: red, green: green, blue: blue, alpha: 1) + } +} diff --git a/Sources/PlaybookUI/Internal/Utilities/UIImage.swift b/Sources/PlaybookUI/Internal/Utilities/UIImage.swift new file mode 100644 index 0000000..37a3b14 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/UIImage.swift @@ -0,0 +1,123 @@ +import SwiftUI + +internal extension UIImage { + static func circle(size: CGFloat) -> UIImage { + let size = CGSize(width: size, height: size) + return UIGraphicsImageRenderer(size: size).image { context in + let rect = CGRect(origin: .zero, size: size) + UIColor.tertiarySystemFill.setFill() + context.cgContext.fillEllipse(in: rect) + } + } + + // Drawn with SwiftDraw: https://github.com/swhitty/SwiftDraw + static let logoMark = { + let size = CGSize(width: 142.0, height: 103.0) + return UIGraphicsImageRenderer(size: size).image { ctx in + let ctx = ctx.cgContext + ctx.saveGState() + + let path = CGMutablePath() + path.move(to: CGPoint(x: 141.03, y: 2.28)) + path.addCurve( + to: CGPoint(x: 139.21, y: 0.15), + control1: CGPoint(x: 141.79, y: 1.21), + control2: CGPoint(x: 140.73, y: 0.15) + ) + path.addLine(to: CGPoint(x: 88.2, y: 0.15)) + path.addLine(to: CGPoint(x: 87.9, y: 0.15)) + path.addCurve( + to: CGPoint(x: 85.47, y: 1.37), + control1: CGPoint(x: 86.84, y: 0.15), + control2: CGPoint(x: 85.92, y: 0.61) + ) + path.addLine(to: CGPoint(x: 16.4, y: 100.95)) + path.addCurve( + to: CGPoint(x: 16.24, y: 101.41), + control1: CGPoint(x: 16.24, y: 101.11), + control2: CGPoint(x: 16.24, y: 101.26) + ) + path.addCurve( + to: CGPoint(x: 17.46, y: 102.32), + control1: CGPoint(x: 16.24, y: 101.86), + control2: CGPoint(x: 16.85, y: 102.32) + ) + path.addLine(to: CGPoint(x: 17.76, y: 102.32)) + path.addLine(to: CGPoint(x: 22.01, y: 102.32)) + path.addLine(to: CGPoint(x: 138.75, y: 102.47)) + path.addCurve( + to: CGPoint(x: 140.58, y: 100.35), + control1: CGPoint(x: 140.27, y: 102.47), + control2: CGPoint(x: 141.34, y: 101.26) + ) + path.addLine(to: CGPoint(x: 107.48, y: 52.07)) + path.addCurve( + to: CGPoint(x: 107.48, y: 50.7), + control1: CGPoint(x: 107.18, y: 51.62), + control2: CGPoint(x: 107.18, y: 51.16) + ) + path.addLine(to: CGPoint(x: 141.03, y: 2.28)) + path.closeSubpath() + ctx.addPath(path) + ctx.clip() + ctx.setAlpha(1) + + let rgb = CGColorSpaceCreateDeviceRGB() + let color1 = CGColor(colorSpace: rgb, components: [0.004, 0.525, 1, 1])! + let color2 = CGColor(colorSpace: rgb, components: [0.376, 0.871, 0.996, 1])! + var locations: [CGFloat] = [0.0, 0.9387] + let gradient = CGGradient( + colorsSpace: rgb, + colors: [color1, color2] as CFArray, + locations: &locations + )! + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: 16.39, y: 51.21), + end: CGPoint(x: 136.7, y: 51.21), + options: [.drawsAfterEndLocation, .drawsBeforeStartLocation] + ) + ctx.restoreGState() + ctx.saveGState() + + let path1 = CGMutablePath() + path1.move(to: CGPoint(x: 69.98, y: 0.15)) + path1.addLine(to: CGPoint(x: 2.58, y: 0)) + path1.addCurve( + to: CGPoint(x: 0.15, y: 1.82), + control1: CGPoint(x: 1.21, y: 0), + control2: CGPoint(x: 0.15, y: 0.76) + ) + path1.addLine(to: CGPoint(x: 0, y: 100.5)) + path1.addCurve( + to: CGPoint(x: 4.71, y: 101.26), + control1: CGPoint(x: 0, y: 102.32), + control2: CGPoint(x: 3.49, y: 102.93) + ) + path1.addLine(to: CGPoint(x: 72.41, y: 2.73)) + path1.addCurve( + to: CGPoint(x: 69.98, y: 0.15), + control1: CGPoint(x: 73.02, y: 1.52), + control2: CGPoint(x: 71.96, y: 0.15) + ) + path1.closeSubpath() + ctx.addPath(path1) + ctx.clip() + ctx.setAlpha(1) + + var locations1: [CGFloat] = [0.0619712, 0.9387] + let gradient1 = CGGradient( + colorsSpace: rgb, + colors: [color1, color2] as CFArray, + locations: &locations1 + )! + ctx.drawLinearGradient( + gradient1, + start: CGPoint(x: -0.18, y: 51.1), + end: CGPoint(x: 72.53, y: 51.1), + options: [.drawsAfterEndLocation, .drawsBeforeStartLocation] + ) + ctx.restoreGState() + } + }() +} diff --git a/Sources/PlaybookUI/Internal/Utilities/View.swift b/Sources/PlaybookUI/Internal/Utilities/View.swift new file mode 100644 index 0000000..9ec977d --- /dev/null +++ b/Sources/PlaybookUI/Internal/Utilities/View.swift @@ -0,0 +1,26 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal extension View { + func textStyle( + font: Font, + color: Color = Color(.label), + alignment: TextAlignment = .leading, + lineLimit: Int? = nil + ) -> some View { + foregroundStyle(color) + .font(font) + .multilineTextAlignment(alignment) + .lineLimit(lineLimit) + .dynamicTypeSize(.large) + } + + func imageStyle( + font: Font, + color: Color = Color(.label) + ) -> some View { + foregroundStyle(color) + .font(font) + .dynamicTypeSize(.large) + } +} diff --git a/Sources/PlaybookUI/Internal/Views/CatalogBottomBar.swift b/Sources/PlaybookUI/Internal/Views/CatalogBottomBar.swift new file mode 100644 index 0000000..759e080 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/CatalogBottomBar.swift @@ -0,0 +1,63 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct CatalogBottomBar: View { + let title: String? + let primaryItemSymbol: Image.SFSymbols + + @EnvironmentObject + private var catalogState: CatalogState + @EnvironmentObject + private var shareState: ShareState + + var body: some View { + VStack(spacing: 0) { + Divider() + .ignoresSafeArea() + + HStack(spacing: 0) { + Button { + catalogState.isSearchPainCollapsed.toggle() + } label: { + Image(symbol: primaryItemSymbol) + .imageStyle(font: .title2) + .padding(8) + } + + Spacer.fixed(length: 8) + + if catalogState.selected != nil { + Button { + shareState.shareSnapshot() + } label: { + Image(symbol: .ellipsisCircle) + .imageStyle(font: .title2) + .padding(8) + } + } + + Spacer(minLength: 0) + + if let title { + Text(title) + .textStyle( + font: .headline, + lineLimit: 1 + ) + .minimumScaleFactor(0.5) + .padding(.horizontal, 8) + } + + Spacer(minLength: 8) + + ColorSchemePicker(colorScheme: $catalogState.colorScheme) + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(height: 56) + .background { + MaterialView() + } + } +} diff --git a/Sources/PlaybookUI/Internal/Views/CatalogDrawer.swift b/Sources/PlaybookUI/Internal/Views/CatalogDrawer.swift new file mode 100644 index 0000000..ee815bf --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/CatalogDrawer.swift @@ -0,0 +1,59 @@ +import Playbook +import SwiftUI + +@available(iOS 15.0, *) +internal struct CatalogDrawer: View { + @EnvironmentObject + private var catalogState: CatalogState + + var body: some View { + ZStack { + CatalogTop() + Drawer(isCollapsed: $catalogState.isSearchPainCollapsed) + } + .onChange(of: catalogState.selected?.id) { _ in + catalogState.isSearchPainCollapsed = true + } + } +} + +@available(iOS 15.0, *) +private struct Drawer: View { + @Binding + var isCollapsed: Bool + + var body: some View { + GeometryReader { geometry in + let drawerWidth = + geometry.safeAreaInsets.leading + + min( + geometry.size.width * 0.8, + max(geometry.size.width, geometry.size.height) * 0.5 + ) + + ZStack { + Color.black + .opacity(isCollapsed ? 0 : 0.3) + .ignoresSafeArea() + .onTapGesture { + isCollapsed = true + } + + HStack(spacing: 0) { + CatalogSearchPane() + .frame(width: drawerWidth) + .background { + Rectangle() + .ignoresSafeArea() + .shadow(radius: 10) + } + + Spacer.zero + } + .offset(x: isCollapsed ? -geometry.size.width : 0) + } + .animation(.smooth(duration: 0.3), value: isCollapsed) + .frame(width: geometry.size.width, height: geometry.size.height) + } + } +} diff --git a/Sources/PlaybookUI/Internal/Views/CatalogKindRow.swift b/Sources/PlaybookUI/Internal/Views/CatalogKindRow.swift new file mode 100644 index 0000000..90363b8 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/CatalogKindRow.swift @@ -0,0 +1,42 @@ +import SwiftUI + +@available(iOS 15, *) +internal struct CatalogKindRow: View { + let data: SearchedKindData + let isExpanded: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Image(uiImage: .logoMark) + .resizable() + .scaledToFit() + .frame(height: 16) + + Spacer.fixed(length: 8) + + HighlightText( + content: data.kind.rawValue, + range: data.highlightRange + ) + .textStyle(font: .headline) + + Spacer(minLength: 8) + + Image(symbol: .chevronRight) + .imageStyle( + font: .caption, + color: Color(.secondaryLabel) + ) + .rotationEffect(.radians(isExpanded ? .pi / 2 : 0)) + } + .padding(16) + + Separator(height: 1) + } + } + .buttonStyle(.borderless) + } +} diff --git a/Sources/PlaybookUI/Internal/Views/CatalogScenarioRow.swift b/Sources/PlaybookUI/Internal/Views/CatalogScenarioRow.swift new file mode 100644 index 0000000..ad839b0 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/CatalogScenarioRow.swift @@ -0,0 +1,37 @@ +import SwiftUI + +@available(iOS 15, *) +internal struct CatalogScenarioRow: View { + let data: SearchedData + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Image(symbol: .circleFill) + .imageStyle( + font: .system(size: 8), + color: Color(isSelected ? .primaryBlue : .translucentFill) + ) + + Spacer.fixed(length: 8) + + HighlightText( + content: data.scenario.name.rawValue, + range: data.highlightRange + ) + .textStyle(font: .subheadline) + + Spacer(minLength: 8) + } + .padding(16) + + Separator(height: 1) + } + .padding(.leading, 16) + } + .buttonStyle(.borderless) + } +} diff --git a/Sources/PlaybookUI/Internal/Views/CatalogSearchPane.swift b/Sources/PlaybookUI/Internal/Views/CatalogSearchPane.swift new file mode 100644 index 0000000..7a1202c --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/CatalogSearchPane.swift @@ -0,0 +1,92 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct CatalogSearchPane: View { + @EnvironmentObject + private var searchState: SearchState + @EnvironmentObject + private var catalogState: CatalogState + @FocusState + private var isFocused + + var body: some View { + VStack(spacing: 0) { + Spacer.fixed(length: 16) + + SearchBar(text: $searchState.query) + .focused($isFocused) + Counter( + count: searchState.result.count, + total: searchState.result.total + ) + + Spacer.fixed(length: 16) + + Divider() + + List { + Group { + if searchState.result.kinds.isEmpty { + UnavailableView( + symbol: .magnifyingglass, + description: "No Result for \"\(searchState.query)\"" + ) + } + else { + ForEach(searchState.result.kinds, id: \.kind) { data in + let isExpanded = catalogState.currentExpandedKinds.contains(data.kind) + + CatalogKindRow(data: data, isExpanded: isExpanded) { + withAnimation(.smooth(duration: 0.1)) { + if isExpanded { + catalogState.currentExpandedKinds.remove(data.kind) + } + else { + catalogState.currentExpandedKinds.insert(data.kind) + } + } + } + + if isExpanded { + ForEach(data.scenarios, id: \.scenario.name) { data in + let select = SelectData( + kind: data.kind, + scenario: data.scenario + ) + + CatalogScenarioRow( + data: data, + isSelected: catalogState.selected?.id == select.id + ) { + catalogState.selected = select + } + } + } + } + } + + Spacer.fixed(length: 16) + } + .listRowSpacing(.zero) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 0) + } + .background { + Color(.secondaryBackground) + .ignoresSafeArea() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: searchState.query) { query in + catalogState.updateSearchingKinds(query: query, searchResult: searchState.result) + } + .onChange(of: catalogState.isSearchPainCollapsed) { isCollapsed in + if isCollapsed { + isFocused = false + } + } + } +} diff --git a/Sources/PlaybookUI/Internal/Views/CatalogSplit.swift b/Sources/PlaybookUI/Internal/Views/CatalogSplit.swift new file mode 100644 index 0000000..523ccac --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/CatalogSplit.swift @@ -0,0 +1,35 @@ +import Playbook +import SwiftUI + +@available(iOS 15.0, *) +internal struct CatalogSplit: View { + @EnvironmentObject + private var catalogState: CatalogState + + var body: some View { + GeometryReader { geometry in + let sidebarWidth = geometry.size.width * 0.4 + + ZStack(alignment: .leading) { + HStack(spacing: 0) { + CatalogSearchPane() + Divider() + .ignoresSafeArea() + } + .frame(width: sidebarWidth) + .offset(x: catalogState.isSearchPainCollapsed ? -sidebarWidth / 2 : 0) + + HStack(spacing: 0) { + Spacer.fixed(length: catalogState.isSearchPainCollapsed ? 0 : sidebarWidth) + CatalogTop() + .transformEnvironment(\.horizontalSizeClass) { sizeClass in + if !catalogState.isSearchPainCollapsed { + sizeClass = .compact + } + } + } + } + } + .animation(.smooth(duration: 0.3), value: catalogState.isSearchPainCollapsed) + } +} diff --git a/Sources/PlaybookUI/Internal/Views/CatalogTop.swift b/Sources/PlaybookUI/Internal/Views/CatalogTop.swift new file mode 100644 index 0000000..d32df1a --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/CatalogTop.swift @@ -0,0 +1,38 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct CatalogTop: View { + @EnvironmentObject + private var catalogState: CatalogState + @EnvironmentObject + private var shareState: ShareState + + var body: some View { + Group { + if let selected = catalogState.selected { + ScenarioContentView( + scenario: selected.scenario, + additionalSafeAreaInsets: UIEdgeInsets( + top: .zero, + left: .zero, + bottom: 56, + right: .zero + ), + shareState: shareState + ) + } + else { + UnavailableView( + symbol: .docTextMagnifyingglass, + description: "No Scenarios found" + ) + } + } + .frame(maxHeight: .infinity) + .background { + Color(.primaryBackground) + .ignoresSafeArea() + } + .ignoresSafeArea() + } +} diff --git a/Sources/PlaybookUI/Internal/Views/ColorSchemePicker.swift b/Sources/PlaybookUI/Internal/Views/ColorSchemePicker.swift new file mode 100644 index 0000000..c2a0282 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/ColorSchemePicker.swift @@ -0,0 +1,32 @@ +import SwiftUI + +@available(iOS 14.0, *) +internal struct ColorSchemePicker: View { + @Binding + var colorScheme: ColorScheme? + @Environment(\.colorScheme) + private var systemColorScheme + + var body: some View { + Picker( + selection: Binding( + get: { colorScheme ?? systemColorScheme }, + set: { colorScheme = $0 } + ), + content: { + ForEach(ColorScheme.allCases, id: \.self) { colorScheme in + switch colorScheme { + case .light: Image(symbol: .sunMax) + case .dark: Image(symbol: .moon) + @unknown default: EmptyView() + } + } + }, + label: { + EmptyView() + } + ) + .pickerStyle(.segmented) + .fixedSize() + } +} diff --git a/Sources/PlaybookUI/Internal/Views/Counter.swift b/Sources/PlaybookUI/Internal/Views/Counter.swift new file mode 100644 index 0000000..cf9c3a7 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/Counter.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct Counter: View { + let count: Int + let total: Int + + var body: some View { + HStack(spacing: 0) { + Spacer.zero + + Text("\(count) / \(total)") + .textStyle( + font: .caption.monospacedDigit(), + color: Color(.secondaryLabel) + ) + } + .padding(.horizontal, 24) + } +} diff --git a/Sources/PlaybookUI/Internal/Views/GalleryDetail.swift b/Sources/PlaybookUI/Internal/Views/GalleryDetail.swift new file mode 100644 index 0000000..7cb149b --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/GalleryDetail.swift @@ -0,0 +1,34 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct GalleryDetail: View { + let data: SelectData + + @StateObject + private var shareState = ShareState() + + var body: some View { + ZStack { + ScenarioContentView( + scenario: data.scenario, + additionalSafeAreaInsets: UIEdgeInsets( + top: 56, + left: .zero, + bottom: .zero, + right: .zero + ), + shareState: shareState + ) + .ignoresSafeArea() + } + .safeAreaInset(edge: .top, spacing: 0) { + GalleryDetailTopBar(title: data.scenario.name.rawValue) { + shareState.shareSnapshot() + } + } + .background { + Color(.primaryBackground) + .ignoresSafeArea() + } + } +} diff --git a/Sources/PlaybookUI/Internal/Views/GalleryDetailTopBar.swift b/Sources/PlaybookUI/Internal/Views/GalleryDetailTopBar.swift new file mode 100644 index 0000000..0e999b9 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/GalleryDetailTopBar.swift @@ -0,0 +1,60 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct GalleryDetailTopBar: View { + let title: String + let share: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Button(action: share) { + Image(symbol: .ellipsisCircle) + .imageStyle(font: .title2) + } + + Spacer(minLength: 16) + + Text(title) + .textStyle(font: .headline, lineLimit: 1) + + Spacer(minLength: 16) + + CloseButton() + } + .padding(.horizontal, 16) + .frame(maxHeight: .infinity) + + Divider() + .ignoresSafeArea() + } + .frame(height: 56) + .background { + MaterialView() + } + } +} + +@available(iOS 15.0, *) +private struct CloseButton: View { + @Environment(\.dismiss) + private var dismiss + + var body: some View { + Button { + dismiss() + } label: { + ZStack { + Circle() + .fill(Color(.translucentFill)) + .frame(width: 30, height: 30) + + Image(symbol: .xmark) + .imageStyle( + font: .subheadline.weight(.bold), + color: Color(.secondaryLabel) + ) + } + } + } +} diff --git a/Sources/PlaybookUI/Internal/Views/GalleryKindRow.swift b/Sources/PlaybookUI/Internal/Views/GalleryKindRow.swift new file mode 100644 index 0000000..546b556 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/GalleryKindRow.swift @@ -0,0 +1,50 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct GalleryKindRow: View { + let data: SearchedKindData + let onSelect: (SearchedData) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(uiImage: .logoMark) + .resizable() + .scaledToFit() + .frame(height: 16) + + HighlightText( + content: data.kind.rawValue, + range: data.highlightRange + ) + .textStyle(font: .headline) + } + .padding(.top, 16) + .padding(.horizontal, 24) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 0) { + ForEach(data.scenarios, id: \.scenario.name) { data in + Button { + onSelect(data) + } label: { + GalleryThumbnail(data: data) + } + .buttonStyle(ScaleButtonStyle()) + } + } + .padding(12) + } + + Separator(height: 2) + } + } +} + +private struct ScaleButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.9 : 1) + .animation(.snappy(duration: 0.2), value: configuration.isPressed) + } +} diff --git a/Sources/PlaybookUI/Internal/Views/GalleryThumbnail.swift b/Sources/PlaybookUI/Internal/Views/GalleryThumbnail.swift new file mode 100644 index 0000000..7e9d29a --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/GalleryThumbnail.swift @@ -0,0 +1,114 @@ +import Playbook +import SwiftUI + +@available(iOS 15.0, *) +internal struct GalleryThumbnail: View { + let data: SearchedData + + @State + private var image: UIImage? + @EnvironmentObject + private var imageLoader: ImageLoader + @Environment(\.colorScheme) + private var colorScheme + private let contentScale: CGFloat = 0.3 + private let imageScale: CGFloat = 0.5 + private let screenSize = UIScreen.main.fixedCoordinateSpace.bounds.size + private let cornerRadius: CGFloat = 16 + + var body: some View { + ZStack { + Color(.primaryBackground) + + if let image { + Image(uiImage: image) + .resizable() + .frame( + width: image.size.width * contentScale / imageScale, + height: image.size.height * contentScale / imageScale + ) + } + else { + Placeholder() + } + } + .frame(width: contentWidth, height: contentHeight, alignment: .top) + .overlay(alignment: .bottom) { + NameLabel(data: data) + } + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(Color(.systemGray5), lineWidth: 4) + } + .cornerRadius(cornerRadius) + .padding(4) + .onChange(of: colorScheme) { _ in + image = nil + } + .task(id: colorScheme) { + let source = ImageSource( + scenario: data.scenario, + kind: data.kind, + size: screenSize, + scale: imageScale, + colorScheme: colorScheme + ) + image = await imageLoader.loadImage(for: source) + } + } +} + +@available(iOS 15, *) +private struct NameLabel: View { + let data: SearchedData + + var body: some View { + VStack(spacing: 0) { + Divider() + .ignoresSafeArea() + + HighlightText( + content: data.scenario.name.rawValue, + range: data.highlightRange + ) + .textStyle(font: .caption, lineLimit: 3) + .padding(4) + .padding(.bottom, 4) + .frame(maxWidth: .infinity) + } + .background { + MaterialView() + } + } +} + +private struct Placeholder: View { + @State + private var isAnimating = false + + var body: some View { + Color(.translucentFill) + .opacity(isAnimating ? 1 : 0) + .animation( + .linear(duration: 0.5) + .repeatForever(autoreverses: true), + value: isAnimating + ) + .onAppear { + if !isAnimating { + isAnimating = true + } + } + } +} + +@available(iOS 15.0, *) +private extension GalleryThumbnail { + var contentWidth: CGFloat { + screenSize.width * contentScale + } + + var contentHeight: CGFloat { + screenSize.height * contentScale + } +} diff --git a/Sources/PlaybookUI/Internal/Views/HighlightText.swift b/Sources/PlaybookUI/Internal/Views/HighlightText.swift new file mode 100644 index 0000000..66a5dd2 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/HighlightText.swift @@ -0,0 +1,30 @@ +import SwiftUI + +@available(iOS 15, *) +internal struct HighlightText: View { + let content: String + let range: Range? + + var body: some View { + if let range { + Text(attributedContent(range: range)) + } + else { + Text(content) + } + } +} + +@available(iOS 15, *) +private extension HighlightText { + func attributedContent(range: Range) -> AttributedString { + var attributed = AttributedString(content) + let nsRange = NSRange(range, in: content) + + if let attributedRange = Range(nsRange, in: attributed) { + attributed[attributedRange].foregroundColor = .init(uiColor: .highlight) + } + + return attributed + } +} diff --git a/Sources/PlaybookUI/Internal/Views/MaterialView.swift b/Sources/PlaybookUI/Internal/Views/MaterialView.swift new file mode 100644 index 0000000..6f715d5 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/MaterialView.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct MaterialView: View { + var body: some View { + Rectangle() + .fill(Material.bar) + .ignoresSafeArea() + } +} diff --git a/Sources/PlaybookUI/Internal/Views/ScenarioContentView.swift b/Sources/PlaybookUI/Internal/Views/ScenarioContentView.swift new file mode 100644 index 0000000..6136089 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/ScenarioContentView.swift @@ -0,0 +1,24 @@ +import Playbook +import SwiftUI + +@available(iOS 14.0, *) +internal struct ScenarioContentView: UIViewControllerRepresentable { + let scenario: Scenario + let additionalSafeAreaInsets: UIEdgeInsets + let shareState: ShareState + + func makeUIViewController(context: Context) -> ScenarioViewController { + let context = ScenarioContext( + snapshotWaiter: SnapshotWaiter(), + isSnapshot: false, + screenSize: UIScreen.main.bounds.size + ) + return ScenarioViewController(context: context) + } + + func updateUIViewController(_ uiViewController: ScenarioViewController, context: Context) { + shareState.scenarioViewController = uiViewController + uiViewController.scenario = scenario + uiViewController.additionalSafeAreaInsets = additionalSafeAreaInsets + } +} diff --git a/Sources/PlaybookUI/Internal/Views/SearchBar.swift b/Sources/PlaybookUI/Internal/Views/SearchBar.swift new file mode 100644 index 0000000..a16e2d3 --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/SearchBar.swift @@ -0,0 +1,78 @@ +import SwiftUI +import UIKit + +@available(iOS 15.0, *) +internal struct SearchBar: View { + @Binding + var text: String + @FocusState + private var isFocused + @State + private var isEditing = false + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: 0) { + Image(symbol: .magnifyingglass) + .imageStyle( + font: .headline.weight(.regular), + color: Color(.secondaryLabel) + ) + .padding(.trailing, 4) + .onTapGesture { + isFocused = true + } + + TextField( + text: $text, + prompt: Text("Search") + .foregroundColor(Color(.secondaryLabel)) + ) { + EmptyView() + } + .textFieldStyle(.plain) + .focused($isFocused) + + if !text.isEmpty { + Button { + text = "" + } label: { + Image(symbol: .xmarkCircleFill) + .foregroundStyle(.gray) + .dynamicTypeSize(.large) + } + .transition(.opacity) + } + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background { + Rectangle() + .fill(Color(.tertiarySystemFill)) + .clipShape(.capsule) + } + + if isEditing { + Spacer.fixed(length: 8) + + Button("Cancel") { + isFocused = false + } + .buttonStyle(.borderless) + .foregroundColor(Color(.primaryBlue)) + .transition( + .move(edge: .trailing) + .combined(with: .opacity) + ) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 16) + .clipped() + .dynamicTypeSize(.large) + .animation(.easeOut(duration: 0.2), value: isEditing) + .onChange(of: isFocused) { isFocused in + isEditing = isFocused + } + } +} diff --git a/Sources/PlaybookUI/Internal/Views/Separator.swift b/Sources/PlaybookUI/Internal/Views/Separator.swift new file mode 100644 index 0000000..1d8d69b --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/Separator.swift @@ -0,0 +1,13 @@ +import SwiftUI + +internal struct Separator: View { + let height: CGFloat + + var body: some View { + Rectangle() + .fill(Color(.translucentFill)) + .frame(height: height) + .padding(.leading, 16) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Sources/PlaybookUI/Internal/Views/UnavailableView.swift b/Sources/PlaybookUI/Internal/Views/UnavailableView.swift new file mode 100644 index 0000000..539ee7f --- /dev/null +++ b/Sources/PlaybookUI/Internal/Views/UnavailableView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +@available(iOS 15.0, *) +internal struct UnavailableView: View { + let symbol: Image.SFSymbols + let description: String + + var body: some View { + VStack(spacing: 16) { + Image(symbol: symbol) + .imageStyle( + font: .largeTitle, + color: Color(.secondaryLabel) + ) + + Text(description) + .textStyle(font: .headline) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 40) + .padding(.horizontal, 24) + } +} diff --git a/Sources/PlaybookUI/Internal/WeakReference.swift b/Sources/PlaybookUI/Internal/WeakReference.swift deleted file mode 100644 index b9b832e..0000000 --- a/Sources/PlaybookUI/Internal/WeakReference.swift +++ /dev/null @@ -1,13 +0,0 @@ -@propertyWrapper -internal final class WeakReference { - var wrappedValue: Value? { - get { value } - set { value = newValue } - } - - private weak var value: Value? - - init(wrappedValue: Value?) { - value = wrappedValue - } -} diff --git a/Sources/PlaybookUI/PlaybookCatalog.swift b/Sources/PlaybookUI/PlaybookCatalog.swift index 848d0c9..995b9f6 100644 --- a/Sources/PlaybookUI/PlaybookCatalog.swift +++ b/Sources/PlaybookUI/PlaybookCatalog.swift @@ -1,195 +1,65 @@ +import Playbook import SwiftUI -/// A view that displays scenarios manged by given `Playbook` instance with -/// catalog-style appearance. +@available(iOS 15.0, *) public struct PlaybookCatalog: View { - private var underlyingView: PlaybookCatalogInternal + private let title: String? + + @StateObject + private var searchState: SearchState + @StateObject + private var catalogState = CatalogState() + @StateObject + private var shareState = ShareState() + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass + @Environment(\.verticalSizeClass) + private var verticalSizeClass - /// Creates a new view that displays scenarios managed by given `Playbook` instance. - /// - /// - Parameters: - /// - name: A name of `Playbook` to be displayed on the user interface. - /// - playbook: A `Playbook` instance that manages scenarios to be displayed. public init( - name: String = "PLAYBOOK", + title: String? = nil, playbook: Playbook = .default ) { - underlyingView = PlaybookCatalogInternal( - name: name, - playbook: playbook, - store: CatalogStore(playbook: playbook) - ) + self.title = title + self._searchState = StateObject(wrappedValue: SearchState(playbook: playbook)) } - /// Declares the content and behavior of this view. public var body: some View { - underlyingView - } -} + Group { + switch (horizontalSizeClass, verticalSizeClass) { + case (.regular, .regular): + CatalogSplit() -internal struct PlaybookCatalogInternal: View { - var name: String - var playbook: Playbook - - @ObservedObject - var store: CatalogStore - - @WeakReference - var contentUIView: UIView? - - @Environment(\.horizontalSizeClass) - var horizontalSizeClass - - @Environment(\.verticalSizeClass) - var verticalSizeClass - - var body: some View { - platformContent() - .environmentObject(store) - .onAppear(perform: selectFirstScenario) - .sheet(item: $store.shareItem) { item in - ImageSharingView(item: item) { self.store.shareItem = nil } - .edgesIgnoringSafeArea(.all) + default: + CatalogDrawer() } - } -} - -private extension PlaybookCatalogInternal { - var bottomBarHeight: CGFloat { 44 } - - func platformContent() -> some View { - switch (horizontalSizeClass, verticalSizeClass) { - case (.regular, .regular): - return AnyView( - CatalogSplitStyle( - name: name, - searchTree: ScenarioSearchTree(), - content: scenarioContent - ) - ) - - default: - return AnyView( - CatalogDrawerStyle( - name: name, - searchTree: ScenarioSearchTree(), - content: scenarioContent - ) - ) } - } - - func displayView() -> some View { - if let data = store.selectedScenario { - return AnyView( - ScenarioContentView( - kind: data.kind, - scenario: data.scenario, - additionalSafeAreaInsets: .only(bottom: bottomBarHeight), - contentUIView: _contentUIView - ) - .edgesIgnoringSafeArea(.all) + .safeAreaInset(edge: .bottom, spacing: 0) { + CatalogBottomBar( + title: title, + primaryItemSymbol: primaryBarItemSymbol ) } - else { - return AnyView(emptyContent()) + .ignoresSafeArea(.keyboard) + .preferredColorScheme(catalogState.colorScheme) + .environmentObject(searchState) + .environmentObject(catalogState) + .environmentObject(shareState) + .onAppear { + catalogState.selectInitial(searchResult: searchState.result) } } +} - func emptyContent() -> some View { - VStack(spacing: 0) { - HStack { - Spacer.zero - } - - Spacer.zero - - Image(symbol: .book) - .imageScale(.large) - .font(.system(size: 60)) - .foregroundColor(Color(.label)) - - Spacer.fixed(length: 44) - - Text("There are no scenarios") - .foregroundColor(Color(.label)) - .font(.system(size: 24, weight: .bold)) - .lineLimit(nil) - - Spacer.zero - } - .padding(.horizontal, 24) - } - - func scenarioContent(firstBarItem: CatalogBarItem) -> some View { - ZStack { - Color(.scenarioBackground) - .edgesIgnoringSafeArea(.all) - - displayView() - - VStack(spacing: 0) { - Spacer.zero - - Divider() - .edgesIgnoringSafeArea(.all) - - bottomBar(firstBarItem: firstBarItem) - } - } - } - - func bottomBar(firstBarItem: CatalogBarItem) -> some View { - HStack(spacing: 24) { - firstBarItem - - if store.selectedScenario != nil { - CatalogBarItem( - image: Image(symbol: .squareAndArrowUp), - insets: .only(bottom: 4), - action: share - ) - } - - HStack(spacing: 0) { - Spacer(minLength: 0) - - Text(name) - .bold() - .lineLimit(1) - .font(.system(size: 24)) - } - } - .padding(.horizontal, 24) - .frame(height: bottomBarHeight) - .background( - Blur(style: .systemMaterial) - .scaledToFill() - .edgesIgnoringSafeArea(.all), - alignment: .topLeading - ) - } - - func share() { - guard let uiView = contentUIView else { return } - - let image = UIGraphicsImageRenderer(bounds: uiView.bounds).image { _ in - uiView.drawHierarchy(in: uiView.bounds, afterScreenUpdates: true) - } - - store.shareItem = ImageSharingView.Item(image: image) - } +@available(iOS 15.0, *) +private extension PlaybookCatalog { + var primaryBarItemSymbol: Image.SFSymbols { + switch (horizontalSizeClass, verticalSizeClass) { + case (.regular, .regular): + return .sidebarLeft - func selectFirstScenario() { - guard store.selectedScenario == nil, let store = playbook.stores.first, let scenario = store.scenarios.first else { - return + default: + return .magnifyingglass } - - self.store.start() - self.store.selectedScenario = SearchedData( - scenario: scenario, - kind: store.kind, - shouldHighlight: false - ) } } diff --git a/Sources/PlaybookUI/PlaybookGallery.swift b/Sources/PlaybookUI/PlaybookGallery.swift index f9f8222..c693f9e 100644 --- a/Sources/PlaybookUI/PlaybookGallery.swift +++ b/Sources/PlaybookUI/PlaybookGallery.swift @@ -1,342 +1,110 @@ +import Playbook import SwiftUI -/// A view that displays scenarios manged by given `Playbook` instance with -/// gallery-style appearance. +@available(iOS 15.0, *) public struct PlaybookGallery: View { - private let name: String - private let snapshotColorScheme: ColorScheme - private let store: GalleryStore + private let title: String? + + @StateObject + private var searchState: SearchState + @StateObject + private var galleryState = GalleryState() + @StateObject + private var imageLoader = ImageLoader() + @FocusState + private var isFocused - /// Creates a new view that displays scenarios managed by given `Playbook` instance. - /// - /// - Parameters: - /// - name: A name of `Playbook` to be displayed on the user interface. - /// - playbook: A `Playbook` instance that manages scenarios to be displayed. - /// - preSnapshotCountLimit: The limit on the number of snapshot images for preview - /// that can be generated before being displayed. - /// - snapshotColorScheme: The color scheme of the snapshot image for preview. - /// - /// - Note: If the displaying of this view is heavy, you can delay the generation - /// of the snapshot image for preview by lowering `preSnapshotCountLimit`. public init( - name: String = "PLAYBOOK", - playbook: Playbook = .default, - preSnapshotCountLimit: Int = 100, - snapshotColorScheme: ColorScheme = .light + title: String? = nil, + playbook: Playbook = .default ) { - self.name = name - self.snapshotColorScheme = snapshotColorScheme - self.store = GalleryStore( - playbook: playbook, - preSnapshotCountLimit: preSnapshotCountLimit, - screenSize: UIScreen.main.fixedCoordinateSpace.bounds.size, - userInterfaceStyle: snapshotColorScheme.userInterfaceStyle - ) + self.title = title + self._searchState = StateObject(wrappedValue: SearchState(playbook: playbook)) } - /// Declares the content and behavior of this view. - @ViewBuilder public var body: some View { - #if swift(>=5.3) - if #available(iOS 14.0, *) { - PlaybookGalleryIOS14( - name: name, - snapshotColorScheme: snapshotColorScheme, - store: store - ) - } - else { - PlaybookGalleryIOS13( - name: name, - snapshotColorScheme: snapshotColorScheme, - store: store - ) - } - #else - PlaybookGalleryIOS13( - name: name, - snapshotColorScheme: snapshotColorScheme, - store: store - ) - #endif - } -} - -#if swift(>=5.3) -@available(iOS 14.0, *) -internal struct PlaybookGalleryIOS14: View { - var name: String - var snapshotColorScheme: ColorScheme - - @ObservedObject - var store: GalleryStore - - @Environment(\.galleryDependency) - var dependency - - var body: some View { - GeometryReader { geometry in - NavigationView { - ScrollView { - LazyVStack(spacing: .zero) { - SearchBar(text: $store.searchText, height: 44) - .padding(.leading, geometry.safeAreaInsets.leading) - .padding(.trailing, geometry.safeAreaInsets.trailing) + NavigationView { + List { + Group { + Spacer.fixed(length: 16) + SearchBar(text: $searchState.query) + .focused($isFocused) + + Counter( + count: searchState.result.count, + total: searchState.result.total + ) + .onDisappear { + isFocused = false + } - statefulBody(geometry: geometry) + if searchState.result.kinds.isEmpty { + UnavailableView( + symbol: .magnifyingglass, + description: "No Result for \"\(searchState.query)\"" + ) } - } - .ignoresSafeArea(edges: .horizontal) - .navigationBarTitle(name) - .background(Color(.primaryBackground).ignoresSafeArea()) - .sheet(item: $store.selectedScenario) { data in - ScenarioDisplaySheet(data: data) { - store.selectedScenario = nil + else { + ForEach(searchState.result.kinds, id: \.kind) { data in + GalleryKindRow(data: data) { selected in + galleryState.selected = SelectData( + kind: selected.kind, + scenario: selected.scenario + ) + } + } } - .environmentObject(store) + + Rectangle() + .fill(.clear) + .frame(height: 24) + .frame(maxWidth: .infinity) + .contextMenu { + Button { + galleryState.clearImageCache() + } label: { + Text("Clear image cache") + } + } } + .listRowSpacing(.zero) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .transition(.identity) } - .environmentObject(store) - .navigationViewStyle(StackNavigationViewStyle()) - .onAppear { - dependency.scheduler.schedule(on: .main, action: store.prepare) - } - } - } -} - -@available(iOS 14.0, *) -private extension PlaybookGalleryIOS14 { - @ViewBuilder - func statefulBody(geometry: GeometryProxy) -> some View { - switch store.status { - case .ready where store.result.data.isEmpty: - message("This filter resulted in 0 results", font: .headline) - - case .ready: - Counter(numerator: store.result.matchedCount, denominator: store.scenariosCount) - .padding(.leading, geometry.safeAreaInsets.leading) - .padding(.trailing, geometry.safeAreaInsets.trailing) - - ForEach(store.result.data, id: \.kind) { data in - ScenarioDisplayList( - data: data, - safeAreaInsets: geometry.safeAreaInsets, - serialDispatcher: SerialMainDispatcher( - interval: 0.2, - scheduler: dependency.scheduler - ), - onSelect: { store.selectedScenario = $0 } - ) + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 0) + .navigationTitleIfPresent(title) + .background { + Color(.primaryBackground) + .ignoresSafeArea() } - - case .standby: - VStack(spacing: .zero) { - message("Preparing snapshots ...", font: .system(size: 24)) - - Image(symbol: .book) - .imageScale(.large) - .font(.system(size: 60)) - .foregroundColor(Color(.label)) + .ignoresSafeArea(.keyboard) + .sheet(item: $galleryState.selected) { data in + GalleryDetail(data: data) } - } - } - - func message(_ text: String, font: Font) -> some View { - Text(text) - .foregroundColor(Color(.label)) - .font(font) - .bold() - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .padding(.vertical, 44) - .padding(.horizontal, 24) - } -} -#endif - -internal struct PlaybookGalleryIOS13: View { - var name: String - var snapshotColorScheme: ColorScheme - - @ObservedObject - var store: GalleryStore - - @Environment(\.galleryDependency) - var dependency - - var body: some View { - GeometryReader { geometry in - NavigationView { - TableView( - animated: false, - snapshot: self.snapshot(), - configureUIView: self.configureTableview, - row: { self.row(with: $0, geometry: geometry) } - ) - .edgesIgnoringSafeArea(.all) - .navigationBarTitle(self.name) - .sheet(item: self.$store.selectedScenario) { data in - ScenarioDisplaySheet(data: data) { - self.store.selectedScenario = nil - } - .environmentObject(self.store) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ColorSchemePicker(colorScheme: $galleryState.colorScheme) } } - .environmentObject(self.store) - .navigationViewStyle(StackNavigationViewStyle()) - .onAppear { - self.dependency.scheduler.schedule(on: .main, action: self.store.prepare) - } } + .navigationViewStyle(.stack) + .preferredColorScheme(galleryState.colorScheme) + .environmentObject(imageLoader) } } -private extension PlaybookGalleryIOS13 { - enum Status { - case standby - case ready - } - - struct Section: Hashable {} - - enum Row: Hashable { - case scenarios(data: SearchedListData) - case counter(numerator: Int, denominator: Int) - case standby - case empty - - func hash(into hasher: inout Hasher) { - switch self { - case .scenarios(let data): - hasher.combine(data.kind) - - case .counter(let numerator, let denominator): - hasher.combine(numerator) - hasher.combine(denominator) - - case .standby: - break - - case .empty: - break - } - } - - static func == (lhs: Row, rhs: Row) -> Bool { - switch (lhs, rhs) { - case (.scenarios(let lhs), .scenarios(let rhs)): - return lhs.kind == rhs.kind && lhs.shouldHighlight == rhs.shouldHighlight - - case (.counter(let lDenominator, let lNumerator), .counter(let rDenominator, let rNumerator)): - return lDenominator == rDenominator && lNumerator == rNumerator - - case (.standby, .standby), (.empty, .empty): - return true - - default: - return false - } - } - } - - func row(with row: Row, geometry: GeometryProxy) -> some View { - switch row { - case .scenarios(let data): - return AnyView( - ScenarioDisplayList( - data: data, - safeAreaInsets: geometry.safeAreaInsets, - serialDispatcher: SerialMainDispatcher( - interval: 0.2, - scheduler: self.dependency.scheduler - ), - onSelect: { self.store.selectedScenario = $0 } - ) - ) - - case .counter(let numerator, let denominator): - return AnyView(Counter(numerator: numerator, denominator: denominator)) - - case .standby: - return AnyView(standby()) - - case .empty: - return AnyView(message("This filter resulted in 0 results", font: .headline)) - } - } - - func standby() -> some View { - VStack(spacing: 0) { - message("Preparing snapshots ...", font: .system(size: 24)) - - Image(symbol: .book) - .imageScale(.large) - .font(.system(size: 60)) - .foregroundColor(Color(.label)) - } - } - - func message(_ text: String, font: Font) -> some View { - Text(text) - .foregroundColor(Color(.label)) - .font(font) - .bold() - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .padding(.vertical, 44) - .padding(.horizontal, 24) - } - - func snapshot() -> NSDiffableDataSourceSnapshot { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([Section()]) - - switch store.status { - case .ready where store.result.data.isEmpty: - snapshot.appendItems([.empty]) - - case .ready: - let counter = Row.counter( - numerator: store.result.matchedCount, - denominator: store.scenariosCount - ) - snapshot.appendItems([counter] + store.result.data.map { .scenarios(data: $0) }) - - case .standby: - snapshot.appendItems([.standby]) +@available(iOS 14.0, *) +private extension View { + @ViewBuilder + func navigationTitleIfPresent(_ title: S?) -> some View { + if let title { + navigationTitle(title) } - - return snapshot - } - - func configureTableview(_ tableView: UITableView) { - let tableHeaderView = UIHostingController( - rootView: SearchBar(text: $store.searchText, height: 44) - ) - tableHeaderView.view.backgroundColor = .clear - tableHeaderView.view.sizeToFit() - tableView.backgroundColor = .primaryBackground - tableView.separatorStyle = .none - tableView.insetsContentViewsToSafeArea = false - tableView.keyboardDismissMode = .onDrag - tableView.estimatedRowHeight = ScenarioDisplay.scale * store.snapshotLoader.device.size.height + 100 - tableView.tableHeaderView = tableHeaderView.view - tableView.tableFooterView = UIView() - } -} - -private extension ColorScheme { - var userInterfaceStyle: UIUserInterfaceStyle { - switch self { - case .light: - return .light - - case .dark: - return .dark - - @unknown default: - return .light + else { + self } } } diff --git a/Tests/SnapshotTests.swift b/Tests/SnapshotTests.swift index 52cbf3a..1114c9b 100644 --- a/Tests/SnapshotTests.swift +++ b/Tests/SnapshotTests.swift @@ -3,7 +3,6 @@ import XCTest final class SnapshotTests: XCTestCase { func testTakeSnapshot() throws { - print("\(ProcessInfo.processInfo.environment)") guard let directory = ProcessInfo.processInfo.environment["SNAPSHOT_DIR"] else { fatalError("Set directory to the build environment variables with key `SNAPSHOT_DIR`.") }