From 817a7f1c2d91ce110d9e2a25ee8b3eb5c32050e6 Mon Sep 17 00:00:00 2001 From: Martin Rechsteiner Date: Sun, 2 Apr 2023 12:39:32 +0200 Subject: [PATCH] Add new API for PageView implementation in SwiftUI The new API replaces the PagingItem initializer with a new Page struct that let's you define custom header items per page. Each page can define their own custom SwiftUI view as the menu item, and it can be customized based on the selection and progress. The new API also supports result builders, which makes it easier to create PageViews with a fixed number of pages. --- ExampleSwiftUI/ChangeItems.swift | 26 - ExampleSwiftUI/ChangeItemsView.swift | 61 ++ ExampleSwiftUI/CustomizedView.swift | 42 ++ ExampleSwiftUI/DefaultView.swift | 56 +- ExampleSwiftUI/DynamicItemsView.swift | 27 + ExampleSwiftUI/ExampleApp.swift | 15 +- ExampleSwiftUI/InterpolatedView.swift | 67 +++ ExampleSwiftUI/LifecycleView.swift | 41 +- ExampleSwiftUI/SelectedIndexView.swift | 43 +- Parchment.xcodeproj/project.pbxproj | 52 +- Parchment/Classes/PageViewCoordinator.swift | 73 +++ .../Classes/PagingCollectionViewLayout.swift | 4 +- Parchment/Classes/PagingController.swift | 38 +- Parchment/Classes/PagingSizeCache.swift | 22 +- Parchment/Enums/PagingBorderOptions.swift | 4 +- Parchment/Enums/PagingIndicatorOptions.swift | 6 +- Parchment/Structs/Page.swift | 221 +++++++ Parchment/Structs/PageItem.swift | 21 + Parchment/Structs/PageItemBuilder.swift | 48 ++ Parchment/Structs/PageItemCell.swift | 81 +++ Parchment/Structs/PageState.swift | 10 + Parchment/Structs/PageView.swift | 556 ++++++++++++------ .../PagingControllerRepresentableView.swift | 62 ++ README.md | 131 ++++- 24 files changed, 1414 insertions(+), 293 deletions(-) delete mode 100644 ExampleSwiftUI/ChangeItems.swift create mode 100644 ExampleSwiftUI/ChangeItemsView.swift create mode 100644 ExampleSwiftUI/CustomizedView.swift create mode 100644 ExampleSwiftUI/DynamicItemsView.swift create mode 100644 ExampleSwiftUI/InterpolatedView.swift create mode 100644 Parchment/Classes/PageViewCoordinator.swift create mode 100644 Parchment/Structs/Page.swift create mode 100644 Parchment/Structs/PageItem.swift create mode 100644 Parchment/Structs/PageItemBuilder.swift create mode 100644 Parchment/Structs/PageItemCell.swift create mode 100644 Parchment/Structs/PageState.swift create mode 100644 Parchment/Structs/PagingControllerRepresentableView.swift diff --git a/ExampleSwiftUI/ChangeItems.swift b/ExampleSwiftUI/ChangeItems.swift deleted file mode 100644 index d01da057..00000000 --- a/ExampleSwiftUI/ChangeItems.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Parchment -import SwiftUI -import UIKit - -struct ChangeItemsView: View { - @State var items = [ - PagingIndexItem(index: 0, title: "View 0"), - PagingIndexItem(index: 1, title: "View 1"), - PagingIndexItem(index: 2, title: "View 2"), - PagingIndexItem(index: 3, title: "View 3"), - ] - - var body: some View { - PageView(items: items) { item in - Text(item.title) - .font(.largeTitle) - .foregroundColor(.gray) - .onTapGesture { - items = [ - PagingIndexItem(index: 0, title: "View 5"), - PagingIndexItem(index: 1, title: "View 6"), - ] - } - } - } -} diff --git a/ExampleSwiftUI/ChangeItemsView.swift b/ExampleSwiftUI/ChangeItemsView.swift new file mode 100644 index 00000000..a2106420 --- /dev/null +++ b/ExampleSwiftUI/ChangeItemsView.swift @@ -0,0 +1,61 @@ +import Parchment +import SwiftUI +import UIKit + +struct ChangeItemsView: View { + @State var isToggled: Bool = false + + var body: some View { + PageView { + if isToggled { + Page("Title 2") { + VStack { + Text("Page 2") + .font(.largeTitle) + .padding(.bottom) + + Button("Click me") { + isToggled.toggle() + } + } + } + + Page("Title 3") { + VStack { + Text("Page 3") + .font(.largeTitle) + .padding(.bottom) + + Button("Click me") { + isToggled.toggle() + } + } + } + } else { + Page("Title 0") { + VStack { + Text("Page 0") + .font(.largeTitle) + .padding(.bottom) + + Button("Click me") { + isToggled.toggle() + } + } + } + + Page("Title 1") { + VStack { + Text("Page 1") + .font(.largeTitle) + .padding(.bottom) + + Button("Click me") { + isToggled.toggle() + } + } + } + } + } + } +} diff --git a/ExampleSwiftUI/CustomizedView.swift b/ExampleSwiftUI/CustomizedView.swift new file mode 100644 index 00000000..cf774c9f --- /dev/null +++ b/ExampleSwiftUI/CustomizedView.swift @@ -0,0 +1,42 @@ +import Parchment +import SwiftUI +import UIKit + +struct CustomizedView: View { + var body: some View { + PageView { + Page("Title 1") { + VStack(spacing: 25) { + Text("Page 1") + Image(systemName: "arrow.down") + } + .font(.largeTitle) + } + + Page("Title 2") { + VStack(spacing: 25) { + Image(systemName: "arrow.up") + Text("Page 2") + } + .font(.largeTitle) + } + } + .menuItemSize(.fixed(width: 100, height: 60)) + .menuItemSpacing(20) + .menuItemLabelSpacing(30) + .menuBackgroundColor(.white) + .menuInsets(.vertical, 20) + .menuHorizontalAlignment(.center) + .menuPosition(.bottom) + .menuTransition(.scrollAlongside) + .menuInteraction(.swipe) + .contentInteraction(.scrolling) + .contentNavigationOrientation(.vertical) + .selectedScrollPosition(.preferCentered) + .indicatorOptions(.visible(height: 4)) + .indicatorColor(.blue) + .borderOptions(.visible(height: 4)) + .borderColor(.blue.opacity(0.2)) + .foregroundColor(.blue) + } +} diff --git a/ExampleSwiftUI/DefaultView.swift b/ExampleSwiftUI/DefaultView.swift index aed1b870..9226e6b7 100644 --- a/ExampleSwiftUI/DefaultView.swift +++ b/ExampleSwiftUI/DefaultView.swift @@ -3,18 +3,52 @@ import SwiftUI import UIKit struct DefaultView: View { - let items = [ - PagingIndexItem(index: 0, title: "View 0"), - PagingIndexItem(index: 1, title: "View 1"), - PagingIndexItem(index: 2, title: "View 2"), - PagingIndexItem(index: 3, title: "View 3"), - ] - var body: some View { - PageView(items: items) { item in - Text(item.title) - .font(.largeTitle) - .foregroundColor(.gray) + PageView { + Page { _ in + Image(systemName: "star.fill") + .padding() + } content: { + Text("Page 1") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 2") { + Text("Page 2") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 3") { + Text("Page 3") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 4") { + Text("Page 4") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Some very long title") { + Text("Page 5") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 6") { + Text("Page 6") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 7") { + Text("Page 7") + .font(.largeTitle) + .foregroundColor(.gray) + } } } } diff --git a/ExampleSwiftUI/DynamicItemsView.swift b/ExampleSwiftUI/DynamicItemsView.swift new file mode 100644 index 00000000..a4f14994 --- /dev/null +++ b/ExampleSwiftUI/DynamicItemsView.swift @@ -0,0 +1,27 @@ +import Parchment +import SwiftUI +import UIKit + +struct DynamicItemsView: View { + @State var items: [Int] = [0, 1, 2, 3, 4] + + var body: some View { + PageView(items, id: \.self) { item in + Page("Title \(item)") { + VStack { + Text("Page \(item)") + .font(.largeTitle) + .padding(.bottom) + + Button("Click me") { + if items.count > 2 { + items = [5, 6] + } else { + items = [0, 1, 2, 3, 4] + } + } + } + } + } + } +} diff --git a/ExampleSwiftUI/ExampleApp.swift b/ExampleSwiftUI/ExampleApp.swift index 977212c4..5dcbcd27 100644 --- a/ExampleSwiftUI/ExampleApp.swift +++ b/ExampleSwiftUI/ExampleApp.swift @@ -6,10 +6,17 @@ struct ExampleApp: App { WindowGroup { NavigationView { List { - NavigationLink("Default", destination: DefaultView()) - NavigationLink("Change selected index", destination: SelectedIndexView()) - NavigationLink("Lifecycle events", destination: LifecycleView()) - NavigationLink("Change items", destination: ChangeItemsView()) + Text("**Welcome to Parchment**. These examples shows how to use Parchment with SwiftUI. For more advanced examples, see the UIKit examples or reach out on GitHub Discussions.") + + Section { + NavigationLink("Default", destination: DefaultView()) + NavigationLink("Interpolated", destination: InterpolatedView()) + NavigationLink("Customized", destination: CustomizedView()) + NavigationLink("Change selected index", destination: SelectedIndexView()) + NavigationLink("Lifecycle events", destination: LifecycleView()) + NavigationLink("Change items", destination: ChangeItemsView()) + NavigationLink("Dynamic items", destination: DynamicItemsView()) + } } .navigationBarTitleDisplayMode(.inline) } diff --git a/ExampleSwiftUI/InterpolatedView.swift b/ExampleSwiftUI/InterpolatedView.swift new file mode 100644 index 00000000..e221af4d --- /dev/null +++ b/ExampleSwiftUI/InterpolatedView.swift @@ -0,0 +1,67 @@ +import Parchment +import SwiftUI +import UIKit + +struct InterpolatedView: View { + var body: some View { + PageView { + Page { state in + Image(systemName: "star.fill") + .scaleEffect(x: 1 + state.progress, y: 1 + state.progress) + .rotationEffect(Angle(degrees: 180 * state.progress)) + .padding(30 * state.progress + 20) + } content: { + Text("Page 1") + .font(.largeTitle) + .foregroundColor(.gray) + } + Page { state in + Text("Rotate") + .fixedSize() + .rotationEffect(Angle(degrees: 90 * state.progress)) + .padding(.horizontal, 10) + } content: { + Text("Page 2") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page { state in + Text("Tracking") + .tracking(10 * state.progress) + .fixedSize() + .padding() + } content: { + Text("Page 3") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page { state in + Text("Growing") + .fixedSize() + .padding(.vertical) + .padding(.horizontal, 20 * state.progress + 10) + .background(Color.black.opacity(0.1)) + .cornerRadius(6) + } content: { + Text("Page 4") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Normal") { + Text("Page 5") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Normal") { + Text("Page 6") + .font(.largeTitle) + .foregroundColor(.gray) + } + } + .menuItemSize(.selfSizing(estimatedWidth: 100, height: 80)) + } +} diff --git a/ExampleSwiftUI/LifecycleView.swift b/ExampleSwiftUI/LifecycleView.swift index 3f176556..fa195c5b 100644 --- a/ExampleSwiftUI/LifecycleView.swift +++ b/ExampleSwiftUI/LifecycleView.swift @@ -3,27 +3,34 @@ import SwiftUI import UIKit struct LifecycleView: View { - let items = [ - PagingIndexItem(index: 0, title: "View 0"), - PagingIndexItem(index: 1, title: "View 1"), - PagingIndexItem(index: 2, title: "View 2"), - PagingIndexItem(index: 3, title: "View 3"), - ] - var body: some View { - PageView(items: items) { item in - Text(item.title) - .font(.largeTitle) - .foregroundColor(.gray) + PageView { + Page("Title 1") { + Text("Page 1") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 2") { + Text("Page 2") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 3") { + Text("Page 3") + .font(.largeTitle) + .foregroundColor(.gray) + } } - .willScroll { pagingItem in - print("will scroll: ", pagingItem) + .willScroll { item in + print("will scroll: ", item) } - .didScroll { pagingItem in - print("did scroll: ", pagingItem) + .didScroll { item in + print("did scroll: ", item) } - .didSelect { pagingItem in - print("did select: ", pagingItem) + .didSelect { item in + print("did select: ", item) } } } diff --git a/ExampleSwiftUI/SelectedIndexView.swift b/ExampleSwiftUI/SelectedIndexView.swift index ec5f17b9..b081698a 100644 --- a/ExampleSwiftUI/SelectedIndexView.swift +++ b/ExampleSwiftUI/SelectedIndexView.swift @@ -3,22 +3,39 @@ import SwiftUI import UIKit struct SelectedIndexView: View { - var items = [ - PagingIndexItem(index: 0, title: "View 0"), - PagingIndexItem(index: 1, title: "View 1"), - PagingIndexItem(index: 2, title: "View 2"), - PagingIndexItem(index: 3, title: "View 3"), - ] - @State var selectedIndex: Int = 2 + @State var selectedIndex: Int = 0 var body: some View { - PageView(items: items, selectedIndex: $selectedIndex) { item in - Text(item.title) - .font(.largeTitle) - .foregroundColor(.gray) - .onTapGesture { - selectedIndex = 0 + PageView(selectedIndex: $selectedIndex) { + Page("Title 0") { + VStack { + Text("Page 0") + .font(.largeTitle) + .padding(.bottom) + + Button("Click me") { + selectedIndex = 2 + } + } + } + + Page("Title 1") { + Text("Page 1") + .font(.largeTitle) + .foregroundColor(.gray) + } + + Page("Title 2") { + VStack { + Text("Page 2") + .font(.largeTitle) + .padding(.bottom) + + Button("Click me") { + selectedIndex = 0 + } } + } } } } diff --git a/Parchment.xcodeproj/project.pbxproj b/Parchment.xcodeproj/project.pbxproj index 5a4ceb71..ebc22608 100644 --- a/Parchment.xcodeproj/project.pbxproj +++ b/Parchment.xcodeproj/project.pbxproj @@ -85,6 +85,16 @@ 9566F567257983B200CCA8FC /* Resources.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9566F566257983B200CCA8FC /* Resources.xcassets */; }; 9568922B222C525C00AFF250 /* CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9568922A222C525C00AFF250 /* CollectionView.swift */; }; 956EBE5E248BC426003ED4BA /* PagingCollectionViewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956EBE5D248BC426003ED4BA /* PagingCollectionViewLayoutTests.swift */; }; + 956F000A29CCFC6C00477E94 /* InterpolatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956F000929CCFC6C00477E94 /* InterpolatedView.swift */; }; + 956F000C29D872C400477E94 /* PageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956F000B29D872C400477E94 /* PageState.swift */; }; + 956FFFC429BD6E6400477E94 /* PageItemBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFC329BD6E6400477E94 /* PageItemBuilder.swift */; }; + 956FFFC629BD786800477E94 /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFC529BD786800477E94 /* Page.swift */; }; + 956FFFC829BD792100477E94 /* PageItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFC729BD792100477E94 /* PageItemCell.swift */; }; + 956FFFCA29BE090800477E94 /* PageItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFC929BE090800477E94 /* PageItem.swift */; }; + 956FFFCC29BE1FD100477E94 /* ChangeItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFCB29BE1FD100477E94 /* ChangeItemsView.swift */; }; + 956FFFCE29BE235200477E94 /* PagingControllerRepresentableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFCD29BE235200477E94 /* PagingControllerRepresentableView.swift */; }; + 956FFFD029BE23F900477E94 /* PageViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFCF29BE23F900477E94 /* PageViewCoordinator.swift */; }; + 956FFFD229BE273B00477E94 /* CustomizedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956FFFD129BE273B00477E94 /* CustomizedView.swift */; }; 9575BEB32461FEF9002403F6 /* CreateDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575BEB22461FEF9002403F6 /* CreateDistance.swift */; }; 9575BEB52462034B002403F6 /* PagingDistanceRightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575BEB42462034B002403F6 /* PagingDistanceRightTests.swift */; }; 9575BEB72463490B002403F6 /* PagingDistanceCenteredTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9575BEB62463490B002403F6 /* PagingDistanceCenteredTests.swift */; }; @@ -129,7 +139,7 @@ 95F5660F2128707900F2A75E /* PagingMenuItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D1BE1D211E409400E86B72 /* PagingMenuItemSource.swift */; }; 95F83D6426237D2B003B728F /* SelectedIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F83D6326237D2B003B728F /* SelectedIndexView.swift */; }; 95F83D6C26237DA4003B728F /* LifecycleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F83D6B26237DA4003B728F /* LifecycleView.swift */; }; - 95F83D832623804F003B728F /* ChangeItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F83D822623804F003B728F /* ChangeItems.swift */; }; + 95F83D832623804F003B728F /* DynamicItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F83D822623804F003B728F /* DynamicItemsView.swift */; }; 95FE3AF91FFEDBCE00E6F2AD /* PagingDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95FE3AF81FFEDBCE00E6F2AD /* PagingDistance.swift */; }; 95FEEA4524215FCA009B5B64 /* PageViewManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95FEEA4424215FCA009B5B64 /* PageViewManagerTests.swift */; }; 95FEEA4D2423C44A009B5B64 /* MockPageViewManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95FEEA4C2423C44A009B5B64 /* MockPageViewManagerDelegate.swift */; }; @@ -280,6 +290,16 @@ 9566F566257983B200CCA8FC /* Resources.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources.xcassets; sourceTree = ""; }; 9568922A222C525C00AFF250 /* CollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionView.swift; sourceTree = ""; }; 956EBE5D248BC426003ED4BA /* PagingCollectionViewLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingCollectionViewLayoutTests.swift; sourceTree = ""; }; + 956F000929CCFC6C00477E94 /* InterpolatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterpolatedView.swift; sourceTree = ""; }; + 956F000B29D872C400477E94 /* PageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageState.swift; sourceTree = ""; }; + 956FFFC329BD6E6400477E94 /* PageItemBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageItemBuilder.swift; sourceTree = ""; }; + 956FFFC529BD786800477E94 /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; + 956FFFC729BD792100477E94 /* PageItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageItemCell.swift; sourceTree = ""; }; + 956FFFC929BE090800477E94 /* PageItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageItem.swift; sourceTree = ""; }; + 956FFFCB29BE1FD100477E94 /* ChangeItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeItemsView.swift; sourceTree = ""; }; + 956FFFCD29BE235200477E94 /* PagingControllerRepresentableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingControllerRepresentableView.swift; sourceTree = ""; }; + 956FFFCF29BE23F900477E94 /* PageViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewCoordinator.swift; sourceTree = ""; }; + 956FFFD129BE273B00477E94 /* CustomizedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizedView.swift; sourceTree = ""; }; 9575BEB22461FEF9002403F6 /* CreateDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateDistance.swift; sourceTree = ""; }; 9575BEB42462034B002403F6 /* PagingDistanceRightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingDistanceRightTests.swift; sourceTree = ""; }; 9575BEB62463490B002403F6 /* PagingDistanceCenteredTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingDistanceCenteredTests.swift; sourceTree = ""; }; @@ -328,7 +348,7 @@ 95E4BA711FF15EFE008871A3 /* PagingFiniteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingFiniteDataSource.swift; sourceTree = ""; }; 95F83D6326237D2B003B728F /* SelectedIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedIndexView.swift; sourceTree = ""; }; 95F83D6B26237DA4003B728F /* LifecycleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleView.swift; sourceTree = ""; }; - 95F83D822623804F003B728F /* ChangeItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeItems.swift; sourceTree = ""; }; + 95F83D822623804F003B728F /* DynamicItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicItemsView.swift; sourceTree = ""; }; 95FE3AF81FFEDBCE00E6F2AD /* PagingDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingDistance.swift; sourceTree = ""; }; 95FEEA4424215FCA009B5B64 /* PageViewManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewManagerTests.swift; sourceTree = ""; }; 95FEEA4C2423C44A009B5B64 /* MockPageViewManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPageViewManagerDelegate.swift; sourceTree = ""; }; @@ -415,6 +435,12 @@ 3E4283FB1C99CF9000032D95 /* PagingItems.swift */, 952D802E1E37CC09003DCB18 /* PagingTransition.swift */, 950ABE412437BD4D00CAD458 /* PagingNavigationOrientation.swift */, + 956FFFC329BD6E6400477E94 /* PageItemBuilder.swift */, + 956FFFC529BD786800477E94 /* Page.swift */, + 956F000B29D872C400477E94 /* PageState.swift */, + 956FFFC729BD792100477E94 /* PageItemCell.swift */, + 956FFFC929BE090800477E94 /* PageItem.swift */, + 956FFFCD29BE235200477E94 /* PagingControllerRepresentableView.swift */, ); path = Structs; sourceTree = ""; @@ -441,6 +467,7 @@ 3E49C7241C8F5C13006269DD /* PagingViewController.swift */, 955453C32413C80F00923BC8 /* PageViewController.swift */, 95FEEA4E2423F213009B5B64 /* PageViewManager.swift */, + 956FFFCF29BE23F900477E94 /* PageViewCoordinator.swift */, ); path = Classes; sourceTree = ""; @@ -717,14 +744,17 @@ 95D2AE50242BCC9500AC3D46 /* ExampleSwiftUI */ = { isa = PBXGroup; children = ( + 95D2AE5F242BCC9900AC3D46 /* Info.plist */, 95D2AE51242BCC9500AC3D46 /* ExampleApp.swift */, 95D2AE55242BCC9500AC3D46 /* DefaultView.swift */, - 95F83D6326237D2B003B728F /* SelectedIndexView.swift */, + 956F000929CCFC6C00477E94 /* InterpolatedView.swift */, + 956FFFD129BE273B00477E94 /* CustomizedView.swift */, 95F83D6B26237DA4003B728F /* LifecycleView.swift */, - 95F83D822623804F003B728F /* ChangeItems.swift */, + 95F83D6326237D2B003B728F /* SelectedIndexView.swift */, + 95F83D822623804F003B728F /* DynamicItemsView.swift */, + 956FFFCB29BE1FD100477E94 /* ChangeItemsView.swift */, 95D2AE57242BCC9900AC3D46 /* Assets.xcassets */, 95D2AE5C242BCC9900AC3D46 /* LaunchScreen.storyboard */, - 95D2AE5F242BCC9900AC3D46 /* Info.plist */, 95D2AE59242BCC9900AC3D46 /* Preview Content */, ); path = ExampleSwiftUI; @@ -1020,6 +1050,7 @@ 9597F2951E3903F4003FD289 /* UIColor+interpolation.swift in Sources */, 95A0AF001FF707910043B90A /* PagingIndexItem.swift in Sources */, 3E49C72A1C8F5C13006269DD /* PagingViewController.swift in Sources */, + 956FFFCA29BE090800477E94 /* PageItem.swift in Sources */, 950ABE422437BD4D00CAD458 /* PagingNavigationOrientation.swift in Sources */, 95A84B0D20ED46920031520F /* AnyPagingItem.swift in Sources */, 95D790102299CE6100E6EE7C /* PagingViewControllerSizeDelegate.swift in Sources */, @@ -1039,6 +1070,8 @@ 955453C42413C80F00923BC8 /* PageViewController.swift in Sources */, 3E4090A21C88BD0A00800E22 /* PagingIndicatorMetric.swift in Sources */, 95D2AE71242EA40A00AC3D46 /* PageViewControllerDataSource.swift in Sources */, + 956FFFC629BD786800477E94 /* Page.swift in Sources */, + 956F000C29D872C400477E94 /* PageState.swift in Sources */, 955444BE1FC9CCEC001EC26B /* PagingSelectedScrollPosition.swift in Sources */, 3E4189211C9573FA001E0284 /* PagingViewControllerInfiniteDataSource.swift in Sources */, 95868C32200412D8004B392B /* InvalidationState.swift in Sources */, @@ -1060,12 +1093,16 @@ 955444C01FC9CCFF001EC26B /* PagingMenuHorizontalAlignment.swift in Sources */, 3E49C7261C8F5C13006269DD /* PagingCell.swift in Sources */, 95D790162299D56300E6EE7C /* PagingMenuDataSource.swift in Sources */, + 956FFFC429BD6E6400477E94 /* PageItemBuilder.swift in Sources */, 955444BA1FC9CCBF001EC26B /* PagingIndicatorOptions.swift in Sources */, 95D790182299D57A00E6EE7C /* PagingMenuDelegate.swift in Sources */, + 956FFFC829BD792100477E94 /* PageItemCell.swift in Sources */, 3E49C72E1C8F5CCE006269DD /* PagingCellViewModel.swift in Sources */, 95868C2C2003DA87004B392B /* PagingContentInteraction.swift in Sources */, 955444B81FC9CCA6001EC26B /* PagingMenuItemSize.swift in Sources */, + 956FFFCE29BE235200477E94 /* PagingControllerRepresentableView.swift in Sources */, 3E562ACE1CE7CD8C007623B3 /* PagingTitleCell.swift in Sources */, + 956FFFD029BE23F900477E94 /* PageViewCoordinator.swift in Sources */, 95FE3AF91FFEDBCE00E6F2AD /* PagingDistance.swift in Sources */, 95D2AE38242BB24800AC3D46 /* PageViewState.swift in Sources */, ); @@ -1116,9 +1153,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 95F83D832623804F003B728F /* ChangeItems.swift in Sources */, + 95F83D832623804F003B728F /* DynamicItemsView.swift in Sources */, 95F83D6C26237DA4003B728F /* LifecycleView.swift in Sources */, + 956FFFD229BE273B00477E94 /* CustomizedView.swift in Sources */, 95D2AE52242BCC9500AC3D46 /* ExampleApp.swift in Sources */, + 956F000A29CCFC6C00477E94 /* InterpolatedView.swift in Sources */, + 956FFFCC29BE1FD100477E94 /* ChangeItemsView.swift in Sources */, 95F83D6426237D2B003B728F /* SelectedIndexView.swift in Sources */, 95D2AE56242BCC9500AC3D46 /* DefaultView.swift in Sources */, ); diff --git a/Parchment/Classes/PageViewCoordinator.swift b/Parchment/Classes/PageViewCoordinator.swift new file mode 100644 index 00000000..198f67da --- /dev/null +++ b/Parchment/Classes/PageViewCoordinator.swift @@ -0,0 +1,73 @@ +import Foundation + +@available(iOS 13.0, *) +final class PageViewCoordinator: PagingViewControllerDataSource, PagingViewControllerDelegate { + var parent: PagingControllerRepresentableView + + init(_ pagingController: PagingControllerRepresentableView) { + parent = pagingController + } + + func numberOfViewControllers(in _: PagingViewController) -> Int { + return parent.items.count + } + + func pagingViewController( + _: PagingViewController, + viewControllerAt index: Int + ) -> UIViewController { + let item = parent.items[index] + var hostingViewController: UIViewController + + if let item = item as? PageItem { + hostingViewController = item.page.content() + } else if let content = parent.content { + hostingViewController = content(item) + } else { + hostingViewController = UIViewController() + } + + let backgroundColor = parent.options.pagingContentBackgroundColor + hostingViewController.view.backgroundColor = backgroundColor + return hostingViewController + } + + func pagingViewController( + _: PagingViewController, + pagingItemAt index: Int + ) -> PagingItem { + parent.items[index] + } + + func pagingViewController( + _ controller: PagingViewController, + didScrollToItem pagingItem: PagingItem, + startingViewController _: UIViewController?, + destinationViewController _: UIViewController, + transitionSuccessful _: Bool + ) { + if let item = pagingItem as? PageItem, + let index = parent.items.firstIndex(where: { $0.isEqual(to: item) }) { + parent.selectedIndex = index + } + + parent.onDidScroll?(pagingItem) + + } + + func pagingViewController( + _: PagingViewController, + willScrollToItem pagingItem: PagingItem, + startingViewController _: UIViewController, + destinationViewController _: UIViewController + ) { + parent.onWillScroll?(pagingItem) + } + + func pagingViewController( + _: PagingViewController, + didSelectItem pagingItem: PagingItem + ) { + parent.onDidSelect?(pagingItem) + } +} diff --git a/Parchment/Classes/PagingCollectionViewLayout.swift b/Parchment/Classes/PagingCollectionViewLayout.swift index 41e002ca..68ccd359 100644 --- a/Parchment/Classes/PagingCollectionViewLayout.swift +++ b/Parchment/Classes/PagingCollectionViewLayout.swift @@ -164,8 +164,8 @@ open class PagingCollectionViewLayout: UICollectionViewLayout, PagingLayout { // preferred width for each cell. The preferred size is based on // the layout constraints in each cell. case .selfSizing where originalAttributes is PagingCellLayoutAttributes: - if preferredAttributes.frame.width != originalAttributes.frame.width { - let pagingItem = visibleItems.pagingItem(for: originalAttributes.indexPath) + let pagingItem = visibleItems.pagingItem(for: originalAttributes.indexPath) + if preferredAttributes.frame.width != preferredSizeCache[pagingItem.identifier] { preferredSizeCache[pagingItem.identifier] = preferredAttributes.frame.width return true } diff --git a/Parchment/Classes/PagingController.swift b/Parchment/Classes/PagingController.swift index 31d39695..243ad40c 100644 --- a/Parchment/Classes/PagingController.swift +++ b/Parchment/Classes/PagingController.swift @@ -628,11 +628,28 @@ final class PagingController: NSObject { } } - private func configureSizeCache(for _: PagingItem) { - if sizeDelegate != nil { - sizeCache.implementsSizeDelegate = true - sizeCache.sizeForPagingItem = { [weak self] item, selected in - self?.sizeDelegate?.width(for: item, isSelected: selected) + private func configureSizeCache(for pagingItem: PagingItem) { + switch options.menuItemSize { + case .selfSizing: + if #available(iOS 13.0, *), pagingItem is PageItem { + sizeCache.implementsSizeDelegate = true + sizeCache.sizeForPagingItem = { [weak self] item, selected in + guard let self else { return nil } + let item = item as! PageItem + let hostingController = item.page.headerHostingController(self.options) + let state = PageState(progress: selected ? 1 : 0, isSelected: selected) + item.page.header(self.options, state, hostingController) + let size = hostingController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + return size.width + } + } + + case .fixed, .sizeToFit: + if sizeDelegate != nil { + sizeCache.implementsSizeDelegate = true + sizeCache.sizeForPagingItem = { [weak self] item, selected in + return self?.sizeDelegate?.width(for: item, isSelected: selected) + } } } } @@ -653,8 +670,17 @@ extension PagingController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let pagingItem = visibleItems.items[indexPath.item] + var reuseIdentifier: String + + if #available(iOS 13.0, *), + let item = pagingItem as? PageItem { + reuseIdentifier = item.page.reuseIdentifier + } else { + reuseIdentifier = String(describing: type(of: pagingItem)) + } + let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: String(describing: type(of: pagingItem)), + withReuseIdentifier: reuseIdentifier, for: indexPath ) as! PagingCell var selected: Bool = false diff --git a/Parchment/Classes/PagingSizeCache.swift b/Parchment/Classes/PagingSizeCache.swift index 56cd175c..b1dd78d4 100644 --- a/Parchment/Classes/PagingSizeCache.swift +++ b/Parchment/Classes/PagingSizeCache.swift @@ -12,18 +12,12 @@ class PagingSizeCache { init(options: PagingOptions) { self.options = options - let didEnterBackground = UIApplication.didEnterBackgroundNotification - let didReceiveMemoryWarning = UIApplication.didReceiveMemoryWarningNotification - - NotificationCenter.default.addObserver(self, - selector: #selector(applicationDidEnterBackground(notification:)), - name: didEnterBackground, - object: nil) - - NotificationCenter.default.addObserver(self, - selector: #selector(didReceiveMemoryWarning(notification:)), - name: didReceiveMemoryWarning, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(didReceiveMemoryWarning(notification:)), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil + ) } deinit { @@ -58,8 +52,4 @@ class PagingSizeCache { @objc private func didReceiveMemoryWarning(notification _: NSNotification) { clear() } - - @objc private func applicationDidEnterBackground(notification _: NSNotification) { - clear() - } } diff --git a/Parchment/Enums/PagingBorderOptions.swift b/Parchment/Enums/PagingBorderOptions.swift index 6cfd043e..7d937096 100644 --- a/Parchment/Enums/PagingBorderOptions.swift +++ b/Parchment/Enums/PagingBorderOptions.swift @@ -4,7 +4,7 @@ public enum PagingBorderOptions { case hidden case visible( height: CGFloat, - zIndex: Int, - insets: UIEdgeInsets + zIndex: Int = 0, + insets: UIEdgeInsets = .zero ) } diff --git a/Parchment/Enums/PagingIndicatorOptions.swift b/Parchment/Enums/PagingIndicatorOptions.swift index df76c3d8..e7fe99e8 100644 --- a/Parchment/Enums/PagingIndicatorOptions.swift +++ b/Parchment/Enums/PagingIndicatorOptions.swift @@ -4,8 +4,8 @@ public enum PagingIndicatorOptions { case hidden case visible( height: CGFloat, - zIndex: Int, - spacing: UIEdgeInsets, - insets: UIEdgeInsets + zIndex: Int = 1, + spacing: UIEdgeInsets = .zero, + insets: UIEdgeInsets = .zero ) } diff --git a/Parchment/Structs/Page.swift b/Parchment/Structs/Page.swift new file mode 100644 index 00000000..1e51125d --- /dev/null +++ b/Parchment/Structs/Page.swift @@ -0,0 +1,221 @@ +import Foundation +import UIKit +import SwiftUI + +/// The `Page` struct represents a single page in a `PageView`. +/// It contains the view hierarchy for the header and body of the +/// page. You can initialize it with a custom SwiftUI header view +/// using the `init(header:content:)` initializer, or just use +/// the default title initializer `init(_:content:)`. +/// +/// Usage: +/// ``` +/// Page("Page Title") { +/// Text("This is the content of the page.") +/// } +/// +/// Page { _ in +/// Image(systemName: "star.fill") +/// } content: { +/// Text("This is the content of the page.") +/// } +/// ``` +/// +/// Note that the header and content parameters in both +/// initializers are closures that return the view hierarchy for +/// the header and body of the page, respectively. +@available(iOS 13.0, *) +public struct Page { + let reuseIdentifier: String + let registerCell: (UICollectionView) -> Void + let headerHostingController: (PagingOptions) -> UIViewController + let header: (PagingOptions, PageState, UIViewController) -> Void + let content: () -> UIViewController + + /// Creates a new page with the given header and content views. + /// + /// - Parameters: + /// - header: A closure that takes a `PageState` instance as + /// input and returns a `View` that represents the header view + /// for the page. The `PageState` instance will be updated as + /// the page is scrolled, allowing the header view to adjust + /// its appearance accordingly. + /// - content: A closure that returns a `View` that represents + /// the content view for the page. + /// + /// - Returns: A new `Page` instance with the given header and content views. + public init( + @ViewBuilder header: @escaping (PageState) -> Header, + @ViewBuilder content: () -> Content + ) { + let content = content() + + let reuseIdentifier = "CellIdentifier-\(String(describing: Header.self))" + self.reuseIdentifier = reuseIdentifier + + self.registerCell = { collectionView in + collectionView.register( + PageItemCell.self, + forCellWithReuseIdentifier: reuseIdentifier + ) + } + + self.headerHostingController = { options in + let state = PageState(progress: 0, isSelected: false) + let view = PageCustomView( + content: header(state), + options: options, + state: state + ) + return UIHostingController(rootView: view) + } + + self.header = { options, state, viewController in + let hostingController = viewController as! UIHostingController> + let view = PageCustomView( + content: header(state), + options: options, + state: state + ) + hostingController.rootView = view + } + + self.content = { + UIHostingController(rootView: content) + } + } + + /// Creates a new page with the given localized title and content views. + /// + /// - Parameters: + /// - titleKey: A `LocalizedStringKey` that represents the + /// localized title for the page. The title will be shown in a + /// `Text` view as the header of the page. + /// - content: A closure that returns a `View` that represents + /// the content view for the page. + /// + /// - Returns: A new `Page` instance with the given title and content views. + public init( + _ titleKey: LocalizedStringKey, + @ViewBuilder content: () -> Content + ) { + let content = content() + + let reuseIdentifier = "CellIdentifier-PageTitleView" + self.reuseIdentifier = reuseIdentifier + + self.registerCell = { collectionView in + collectionView.register( + PageItemCell.self, + forCellWithReuseIdentifier: reuseIdentifier + ) + } + + self.headerHostingController = { options in + let header = PageTitleView( + content: Text(titleKey), + options: options, + progress: 0 + ) + return UIHostingController(rootView: header) + } + + self.header = { options, state, viewController in + let hostingController = viewController as! UIHostingController + let header = PageTitleView( + content: Text(titleKey), + options: options, + progress: state.progress + ) + hostingController.rootView = header + } + + self.content = { + UIHostingController(rootView: content) + } + } + + /// Creates a new page with the given title and content views. + /// + /// - Parameters: + /// - titleKey: A `StringProtocol` instance that represents + /// the title for the page. The title will be shown in a + /// `Text` view as the header of the page. + /// - content: A closure that returns a `View` that represents + /// the content view for the page. + /// + /// - Returns: A new `Page` instance with the given title and content views. + public init( + _ title: Title, + @ViewBuilder content: () -> Content + ) { + let content = content() + + let reuseIdentifier = "CellIdentifier-PageTitleView" + self.reuseIdentifier = reuseIdentifier + + self.registerCell = { collectionView in + collectionView.register( + PageItemCell.self, + forCellWithReuseIdentifier: reuseIdentifier + ) + } + + self.headerHostingController = { options in + let header = PageTitleView( + content: Text(title), + options: options, + progress: 0 + ) + return UIHostingController(rootView: header) + } + + self.header = { options, state, viewController in + let hostingController = viewController as! UIHostingController + let header = PageTitleView( + content: Text(title), + options: options, + progress: state.progress + ) + hostingController.rootView = header + } + + self.content = { + UIHostingController(rootView: content) + } + } +} + +@available(iOS 13.0, *) +struct PageCustomView: View { + let content: Content + let options: PagingOptions + let state: PageState + + var body: some View { + content + .foregroundColor(Color(UIColor.interpolate( + from: options.textColor, + to: options.selectedTextColor, + with: state.progress + ))) + } +} + +@available(iOS 13.0, *) +struct PageTitleView: View { + let content: Text + let options: PagingOptions + let progress: CGFloat + + var body: some View { + content + .fixedSize() + .padding(.horizontal, options.menuItemLabelSpacing) + .foregroundColor(Color(UIColor.interpolate( + from: options.textColor, + to: options.selectedTextColor, + with: progress + ))) + } +} diff --git a/Parchment/Structs/PageItem.swift b/Parchment/Structs/PageItem.swift new file mode 100644 index 00000000..d3ba8759 --- /dev/null +++ b/Parchment/Structs/PageItem.swift @@ -0,0 +1,21 @@ +import Foundation + +@available(iOS 13.0, *) +struct PageItem: PagingItem, Hashable, Comparable { + let identifier: Int + let index: Int + let page: Page + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + hasher.combine(index) + } + + static func == (lhs: PageItem, rhs: PageItem) -> Bool { + return lhs.identifier == rhs.identifier && lhs.index == rhs.index + } + + static func < (lhs: PageItem, rhs: PageItem) -> Bool { + return lhs.index < rhs.index + } +} diff --git a/Parchment/Structs/PageItemBuilder.swift b/Parchment/Structs/PageItemBuilder.swift new file mode 100644 index 00000000..0b124ba5 --- /dev/null +++ b/Parchment/Structs/PageItemBuilder.swift @@ -0,0 +1,48 @@ +import SwiftUI + +@available(iOS 13.0, *) +@resultBuilder +public struct PageBuilder { + public static func buildExpression(_ expression: Page) -> [Page] { + return [expression] + } + + public static func buildExpression(_ expression: Page?) -> [Page] { + if let expression = expression { + return [expression] + } + return [] + } + + public static func buildExpression(_ expression: [Page]) -> [Page] { + return expression + } + + public static func buildBlock(_ components: [Page]) -> [Page] { + return components + } + + public static func buildBlock(_ components: [Page]...) -> [Page] { + return components.reduce([], +) + } + + public static func buildArray(_ components: [[Page]]) -> [Page] { + return components.flatMap { $0 } + } + + public static func buildOptional(_ component: [Page]?) -> [Page] { + return component ?? [] + } + + public static func buildEither(first component: [Page]) -> [Page] { + return component + } + + public static func buildEither(second component: [Page]) -> [Page] { + return component + } + + public static func buildFor(_ component: [Page]) -> [Page] { + return component + } +} diff --git a/Parchment/Structs/PageItemCell.swift b/Parchment/Structs/PageItemCell.swift new file mode 100644 index 00000000..e81dda87 --- /dev/null +++ b/Parchment/Structs/PageItemCell.swift @@ -0,0 +1,81 @@ +import UIKit +import Foundation + +@available(iOS 13.0, *) +final class PageItemCell: PagingCell { + private var item: PageItem! + private var options: PagingOptions? + private var itemSelected: Bool = false + private var hostingController: UIViewController? + + override func didMoveToWindow() { + super.didMoveToWindow() + if hostingController?.parent == nil { + addHostingController() + } + } + + override func systemLayoutSizeFitting( + _ targetSize: CGSize, + withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, + verticalFittingPriority: UILayoutPriority + ) -> CGSize { + guard let hostingController else { return .zero } + + return hostingController.view.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .fittingSizeLevel, + verticalFittingPriority: .defaultLow + ) + } + + override func setPagingItem(_ pagingItem: PagingItem, selected: Bool, options: PagingOptions) { + item = pagingItem as? PageItem + self.options = options + self.itemSelected = selected + + if let hostingController = hostingController { + let state = PageState(progress: selected ? 1 : 0, isSelected: selected) + item.page.header(options, state, hostingController) + } + } + + override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { + super.apply(layoutAttributes) + if let attributes = layoutAttributes as? PagingCellLayoutAttributes { + if let hostingController, let options { + let state = PageState(progress: attributes.progress, isSelected: itemSelected) + item.page.header(options, state, hostingController) + } + } + } + + private func addHostingController() { + if let item = item, + let options = options, + let parentViewController = parentViewController() { + let viewController = item.page.headerHostingController(options) + viewController.view.backgroundColor = options.backgroundColor + viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + viewController.view.frame = contentView.bounds + contentView.addSubview(viewController.view) + parentViewController.addChild(viewController) + viewController.didMove(toParent: parentViewController) + hostingController = viewController + + let state = PageState(progress: itemSelected ? 1 : 0, isSelected: itemSelected) + item.page.header(options, state, viewController) + } + } + + private func parentViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + return viewController + } + responder = nextResponder + } + return nil + } +} diff --git a/Parchment/Structs/PageState.swift b/Parchment/Structs/PageState.swift new file mode 100644 index 00000000..414ec0ca --- /dev/null +++ b/Parchment/Structs/PageState.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Represents the current state of a page. This will be passed into +/// the `Page` struct while scrolling, and can be used to update the +/// appearance of the corresponding menu item to reflect the current +/// progress and selection state. +public struct PageState { + public let progress: CGFloat + public let isSelected: Bool +} diff --git a/Parchment/Structs/PageView.swift b/Parchment/Structs/PageView.swift index 26588b49..51f1267c 100644 --- a/Parchment/Structs/PageView.swift +++ b/Parchment/Structs/PageView.swift @@ -1,203 +1,405 @@ import SwiftUI import UIKit -/// Check if both SwiftUI and Combine is available. Without this -/// xcodebuild fails, saying it can't find the SwiftUI types used -/// inside PageView, even though it's wrapped with an @available -/// check. Found a possible fix here: https://stackoverflow.com/questions/58233454/how-to-use-swiftui-in-framework -/// This might be related to the issue discussed in this thread: -/// https://forums.swift.org/t/weak-linking-of-frameworks-with-greater-deployment-targets/26017/24 -#if canImport(SwiftUI) && !(os(iOS) && (arch(i386) || arch(arm))) - - /// `PageView` provides a SwiftUI wrapper around `PagingViewController`. - /// It can be used with any fixed array of `PagingItem`s. Use the - /// `PagingOptions` struct to customize the properties. - @available(iOS 13.0, *) - public struct PageView: View { - let content: (Item) -> Page - - private let options: PagingOptions - private var items = [Item]() - private var onWillScroll: ((PagingItem) -> Void)? - private var onDidScroll: ((PagingItem) -> Void)? - private var onDidSelect: ((PagingItem) -> Void)? - @Binding private var selectedIndex: Int - - /// Initialize a new `PageView`. - /// - /// - Parameters: - /// - options: The configuration parameters we want to customize. - /// - items: The array of `PagingItem`s to display in the menu. - /// - selectedIndex: The index of the currently selected page. - /// Updating this index will transition to the new index. - /// - content: A callback that returns the `View` for each item. - public init( - options: PagingOptions = PagingOptions(), - items: [Item], - selectedIndex: Binding = .constant(Int.max), - content: @escaping (Item) -> Page - ) { - _selectedIndex = selectedIndex - self.options = options - self.items = items - self.content = content - } +/// The `PageView` type is a SwiftUI view that allows the user to page +/// between views while displaying a menu that moves with the +/// content. It is a wrapper around the `PagingViewController` class +/// in `Parchment`. To use the `PageView` type, create a new instance +/// with a closure that returns an array of `Page` instances. Each +/// `Page` instance contains a menu item view and a closure that +/// returns the body of the page, which can be any SwiftUI view. +/// +/// Usage: +/// ``` +/// PageView { +/// Page("Title 0") { +/// Text("Page 0") +/// } +/// +/// Page("Title 1") { +/// Text("Page 1") +/// } +/// } +/// ``` +@available(iOS 13.0, *) +public struct PageView: View { + private let items: [PagingItem] + private var content: ((PagingItem) -> UIViewController)? + private var options: PagingOptions + private var onWillScroll: ((PagingItem) -> Void)? + private var onDidScroll: ((PagingItem) -> Void)? + private var onDidSelect: ((PagingItem) -> Void)? - public var body: some View { - PagingController( - items: items, - options: options, - content: content, - onWillScroll: onWillScroll, - onDidScroll: onDidScroll, - onDidSelect: onDidSelect, - selectedIndex: $selectedIndex - ) - } + @Binding private var selectedIndex: Int - /// Called when the user finished scrolling to a new view. - /// - /// - Parameter action: A closure that is called with the - /// paging item that was scrolled to. - /// - Returns: An instance of self - public func didScroll(_ action: @escaping (PagingItem) -> Void) -> Self { - var view = self - view.onDidScroll = action - return view - } + static func defaultOptions() -> PagingOptions { + var options = PagingOptions() + options.menuItemSize = .selfSizing(estimatedWidth: 50, height: 50) + options.menuInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + options.indicatorOptions = .visible(height: 4, zIndex: .max, spacing: .zero, insets: .zero) + options.pagingContentBackgroundColor = .clear + options.menuBackgroundColor = .clear + options.backgroundColor = .clear + options.selectedBackgroundColor = .clear + return options + } - /// Called when the user is about to start scrolling to a new view. - /// - /// - Parameter action: A closure that is called with the - /// paging item that is being scrolled to. - /// - Returns: An instance of self - public func willScroll(_ action: @escaping (PagingItem) -> Void) -> Self { - var view = self - view.onWillScroll = action - return view - } + /// Initializes a new `PageView` with the specified content. + /// + /// - Parameters: + /// - selectedIndex: A binding to an integer value that + /// represents the index of the currently selected + /// page. Defaults to a constant binding with a value of + /// `Int.max`, indicating no page is currently selected. + /// - content: A closure that returns an array of `Page` + /// instances. The `Page` type is a struct that represents the + /// content of a single page in the `PageView`. The closure + /// must return an array of `Page` instances, which will be + /// used to construct the `PageView`. + /// + /// - Returns: A new instance of `PageView`, initialized with the + /// selected index, and content. + public init( + selectedIndex: Binding = .constant(Int.max), + @PageBuilder content: () -> [Page] + ) { + _selectedIndex = selectedIndex + self.options = PageView.defaultOptions() + self.items = content() + .enumerated() + .map { (index, page) in + // TODO: What should we use as the identifier? + PageItem( + identifier: index, + index: index, + page: page + ) + } + } - /// Called when an item was selected in the menu. - /// - /// - Parameter action: A closure that is called with the - /// selected paging item. - /// - Returns: An instance of self - public func didSelect(_ action: @escaping (PagingItem) -> Void) -> Self { - var view = self - view.onDidSelect = action - return view - } + /// Initializes a new `PageView` based on the specified data. + /// + /// - Parameters: + /// - data: The identified data that the `PageView` instance + /// uses to create pages dynamically. + /// - selectedIndex: A binding to an integer value that + /// represents the index of the currently selected + /// page. Defaults to a constant binding with a value of + /// `Int.max`, indicating no page is currently selected. + /// - content: A page builder that creates pages + /// dynamically. The `Page` type is a struct that represents + /// the content of a single page in the `PageView`. + /// + /// - Returns: A new instance of `PageView`, initialized with the + /// selected index, and content. + public init( + _ data: Data, + selectedIndex: Binding = .constant(Int.max), + content: (Data.Element) -> Page + ) where Data.Element: Identifiable { + _selectedIndex = selectedIndex + self.options = PageView.defaultOptions() + self.items = data + .enumerated() + .map { (index, item) in + PageItem( + identifier: item.id.hashValue, + index: index, + page: content(item) + ) + } + } + + /// Initializes a new `PageView` based on the specified data. + /// + /// - Parameters: + /// - data: The identified data that the `PageView` instance + /// uses to create pages dynamically. + /// - id: The key path to the provided data's identifier. + /// - selectedIndex: A binding to an integer value that + /// represents the index of the currently selected + /// page. Defaults to a constant binding with a value of + /// `Int.max`, indicating no page is currently selected. + /// - content: A page builder that creates pages + /// dynamically. The `Page` type is a struct that represents + /// the content of a single page in the `PageView`. + /// + /// - Returns: A new instance of `PageView`, initialized with the + /// selected index, and content. + public init( + _ data: Data, + id: KeyPath, + selectedIndex: Binding = .constant(Int.max), + content: (Data.Element) -> Page + ) { + _selectedIndex = selectedIndex + self.options = PageView.defaultOptions() + self.items = data + .enumerated() + .map { (index, item) in + PageItem( + identifier: item[keyPath: id].hashValue, + index: index, + page: content(item) + ) + } + } - /// Create a custom paging view controller subclass that we - /// can use to store state to avoid reloading data unnecessary. - final class CustomPagingViewController: PagingViewController { - var items: [Item]? + /// Initialize a new `PageView`. + /// + /// - Parameters: + /// - options: The configuration parameters we want to customize. + /// - items: The array of `PagingItem`s to display in the menu. + /// - selectedIndex: The index of the currently selected page. + /// Updating this index will transition to the new index. + /// - content: A callback that returns the `View` for each item. + @available(*, deprecated, message: "This method is no longer recommended. Use the new Page initializers instead.") + public init( + options: PagingOptions = PagingOptions(), + items: [Item], + selectedIndex: Binding = .constant(Int.max), + content: @escaping (Item) -> Page + ) { + _selectedIndex = selectedIndex + self.options = options + self.items = items + self.content = { item in + let content = content(item as! Item) + return UIHostingController(rootView: content) } + } - struct PagingController: UIViewControllerRepresentable { - let items: [Item] - let options: PagingOptions - let content: (Item) -> Page - var onWillScroll: ((PagingItem) -> Void)? - var onDidScroll: ((PagingItem) -> Void)? - var onDidSelect: ((PagingItem) -> Void)? + public var body: some View { + PagingControllerRepresentableView( + items: items, + content: content, + options: options, + onWillScroll: onWillScroll, + onDidScroll: onDidScroll, + onDidSelect: onDidSelect, + selectedIndex: $selectedIndex + ) + } +} - @Binding var selectedIndex: Int +@available(iOS 13.0, *) +extension PageView { + /// Called when the user finished scrolling to a new view. + /// + /// - Parameter action: A closure that is called with the + /// paging item that was scrolled to. + /// - Returns: An instance of self + public func didScroll(_ action: @escaping (PagingItem) -> Void) -> Self { + var view = self + view.onDidScroll = action + return view + } - func makeCoordinator() -> Coordinator { - Coordinator(self) - } + /// Called when the user is about to start scrolling to a new view. + /// + /// - Parameter action: A closure that is called with the + /// paging item that is being scrolled to. + /// - Returns: An instance of self + public func willScroll(_ action: @escaping (PagingItem) -> Void) -> Self { + var view = self + view.onWillScroll = action + return view + } - func makeUIViewController(context: UIViewControllerRepresentableContext) -> CustomPagingViewController { - let pagingViewController = CustomPagingViewController(options: options) - pagingViewController.dataSource = context.coordinator - pagingViewController.delegate = context.coordinator - return pagingViewController - } + /// Called when an item was selected in the menu. + /// + /// - Parameter action: A closure that is called with the + /// selected paging item. + /// - Returns: An instance of self + public func didSelect(_ action: @escaping (PagingItem) -> Void) -> Self { + var view = self + view.onDidSelect = action + return view + } - func updateUIViewController(_ pagingViewController: CustomPagingViewController, - context: UIViewControllerRepresentableContext) { - context.coordinator.parent = self - - if pagingViewController.dataSource == nil { - pagingViewController.dataSource = context.coordinator - } - - // If the menu items have changed we call reload data - // to update both the menu and content views. - if let previousItems = pagingViewController.items, - !previousItems.elementsEqual(items, by: { $0.isEqual(to: $1) }) { - pagingViewController.reloadData() - } - - // Store the current items so we can compare it with - // the new items the next time this method is called. - pagingViewController.items = items - - // HACK: If the user don't pass a selectedIndex binding, the - // default parameter is set to .constant(Int.max) which allows - // us to check here if a binding was passed in or not (it - // doesn't seem possible to make the binding itself optional). - // This check is needed because we cannot update a .constant - // value. When the user scroll to another page, the - // selectedIndex binding will always be the same, so calling - // `select(index:)` will select the wrong page. This fixes a bug - // where the wrong page would be selected when rotating. - guard selectedIndex != Int.max else { - return - } - - pagingViewController.select(index: selectedIndex, animated: true) - } + /// The size for each of the menu items. + /// + /// Default: + /// ``` + /// .selfSizing(estimatedWidth: 50, height: 50) + /// ``` + public func menuItemSize(_ size: PagingMenuItemSize) -> Self { + var view = self + view.options.menuItemSize = size + return view + } + + /// Determine the spacing between the menu items. + public func menuItemSpacing(_ spacing: CGFloat) -> Self { + var view = self + view.options.menuItemSpacing = spacing + return view + } + + /// Determine the horizontal spacing around the title label. This + /// only applies when using the default string initializer. + public func menuItemLabelSpacing(_ spacing: CGFloat) -> Self { + var view = self + view.options.menuItemLabelSpacing = spacing + return view + } + + /// Determine the insets at around all the menu items, + public func menuInsets(_ insets: EdgeInsets) -> Self { + var view = self + view.options.menuInsets = UIEdgeInsets( + top: insets.top, + left: insets.leading, + bottom: insets.bottom, + right: insets.trailing + ) + return view + } + + /// Determine the insets at around all the menu items. + public func menuInsets(_ edges: SwiftUI.Edge.Set, _ length: CGFloat) -> Self { + var view = self + if edges.contains(.all) { + view.options.menuInsets.top = length + view.options.menuInsets.bottom = length + view.options.menuInsets.left = length + view.options.menuInsets.right = length + } + if edges.contains(.vertical) { + view.options.menuInsets.top = length + view.options.menuInsets.bottom = length + } + if edges.contains(.horizontal) { + view.options.menuInsets.left = length + view.options.menuInsets.right = length } + if edges.contains(.top) { + view.options.menuInsets.top = length + } + if edges.contains(.bottom) { + view.options.menuInsets.bottom = length + } + if edges.contains(.leading) { + view.options.menuInsets.left = length + } + if edges.contains(.trailing) { + view.options.menuInsets.right = length + } + return view + } - final class Coordinator: PagingViewControllerDataSource, PagingViewControllerDelegate { - var parent: PagingController + /// Determine the insets at around all the menu items. + public func menuInsets(_ length: CGFloat) -> Self { + var view = self + view.options.menuInsets = UIEdgeInsets( + top: length, + left: length, + bottom: length, + right: length + ) + return view + } - init(_ pagingController: PagingController) { - parent = pagingController - } + /// Determine whether the menu items should be centered when all + /// the items can fit within the bounds of the view. + public func menuHorizontalAlignment(_ alignment: PagingMenuHorizontalAlignment) -> Self { + var view = self + view.options.menuHorizontalAlignment = alignment + return view + } - func numberOfViewControllers(in _: PagingViewController) -> Int { - return parent.items.count - } + /// Determine the position of the menu relative to the content. + public func menuPosition(_ position: PagingMenuPosition) -> Self { + var view = self + view.options.menuPosition = position + return view + } - func pagingViewController(_: PagingViewController, viewControllerAt index: Int) -> UIViewController { - let view = parent.content(parent.items[index]) - let hostingViewController = UIHostingController(rootView: view) - let backgroundColor = parent.options.pagingContentBackgroundColor - hostingViewController.view.backgroundColor = backgroundColor - return hostingViewController - } + /// Determine the transition behaviour of menu items while + /// scrolling the content. + public func menuTransition(_ transition: PagingMenuTransition) -> Self { + var view = self + view.options.menuTransition = transition + return view + } - func pagingViewController(_: PagingViewController, pagingItemAt index: Int) -> PagingItem { - parent.items[index] - } + /// Determine how users can interact with the menu items. + public func menuInteraction(_ interaction: PagingMenuInteraction) -> Self { + var view = self + view.options.menuInteraction = interaction + return view + } + + /// Determine how users can interact with the page view + /// controller. + public func contentInteraction(_ interaction: PagingContentInteraction) -> Self { + var view = self + view.options.contentInteraction = interaction + return view + } - func pagingViewController(_ controller: PagingViewController, - didScrollToItem pagingItem: PagingItem, - startingViewController _: UIViewController?, - destinationViewController _: UIViewController, - transitionSuccessful _: Bool) { - if let item = pagingItem as? Item, - let index = parent.items.firstIndex(where: { $0.isEqual(to: item) }) { - parent.selectedIndex = index - } + /// Determine how the selected menu item should be aligned when it + /// is selected. Effectively the same as the + /// `UICollectionViewScrollPosition`. + public func selectedScrollPosition(_ position: PagingSelectedScrollPosition) -> Self { + var view = self + view.options.selectedScrollPosition = position + return view + } - parent.onDidScroll?(pagingItem) + /// Add an indicator view to the selected menu item. The indicator + /// width will be equal to the selected menu items width. Insets + /// only apply horizontally. + public func indicatorOptions(_ options: PagingIndicatorOptions) -> Self { + var view = self + view.options.indicatorOptions = options + return view + } - } + /// Add a border at the bottom of the menu items. The border will + /// be as wide as the menu items. Insets only apply horizontally. + public func borderOptions(_ options: PagingBorderOptions) -> Self { + var view = self + view.options.borderOptions = options + return view + } - func pagingViewController(_: PagingViewController, - willScrollToItem pagingItem: PagingItem, - startingViewController _: UIViewController, - destinationViewController _: UIViewController) { - parent.onWillScroll?(pagingItem) - } + /// The scroll navigation orientation of the content in the page + /// view controller. + public func contentNavigationOrientation(_ orientation: PagingNavigationOrientation) -> Self { + var view = self + view.options.contentNavigationOrientation = orientation + return view + } +} - func pagingViewController(_: PagingViewController, didSelectItem pagingItem: PagingItem) { - parent.onDidSelect?(pagingItem) - } - } +@available(iOS 14.0, *) +extension PageView { + /// Determine the color of the indicator view. + public func indicatorColor(_ color: Color) -> Self { + var view = self + view.options.indicatorColor = UIColor(color) + return view + } + + /// Determine the color of the border view. + public func borderColor(_ color: Color) -> Self { + var view = self + view.options.borderColor = UIColor(color) + return view + } + + /// The background color for the view behind the menu items. + public func menuBackgroundColor(_ color: Color) -> Self { + var view = self + view.options.menuBackgroundColor = UIColor(color) + return view + } + + // The background color for the paging contents. + public func contentBackgroundColor(_ color: Color) -> Self { + var view = self + view.options.pagingContentBackgroundColor = UIColor(color) + return view } -#endif +} diff --git a/Parchment/Structs/PagingControllerRepresentableView.swift b/Parchment/Structs/PagingControllerRepresentableView.swift new file mode 100644 index 00000000..edff2465 --- /dev/null +++ b/Parchment/Structs/PagingControllerRepresentableView.swift @@ -0,0 +1,62 @@ +import UIKit +import SwiftUI + +@available(iOS 13.0, *) +struct PagingControllerRepresentableView: UIViewControllerRepresentable { + let items: [PagingItem] + let content: ((PagingItem) -> UIViewController)? + let options: PagingOptions + var onWillScroll: ((PagingItem) -> Void)? + var onDidScroll: ((PagingItem) -> Void)? + var onDidSelect: ((PagingItem) -> Void)? + + @Binding var selectedIndex: Int + + func makeCoordinator() -> PageViewCoordinator { + PageViewCoordinator(self) + } + + func makeUIViewController( + context: UIViewControllerRepresentableContext + ) -> PagingViewController { + let pagingViewController = PagingViewController(options: options) + pagingViewController.dataSource = context.coordinator + pagingViewController.delegate = context.coordinator + + if let items = items as? [PageItem] { + for item in items { + item.page.registerCell(pagingViewController.collectionView) + } + } + + return pagingViewController + } + + func updateUIViewController( + _ pagingViewController: PagingViewController, + context: UIViewControllerRepresentableContext + ) { + context.coordinator.parent = self + + if pagingViewController.dataSource == nil { + pagingViewController.dataSource = context.coordinator + } + + pagingViewController.reloadData() + + // HACK: If the user don't pass a selectedIndex binding, the + // default parameter is set to .constant(Int.max) which allows + // us to check here if a binding was passed in or not (it + // doesn't seem possible to make the binding itself optional). + // This check is needed because we cannot update a .constant + // value. When the user scroll to another page, the + // selectedIndex binding will always be the same, so calling + // `select(index:)` will select the wrong page. This fixes a bug + // where the wrong page would be selected when rotating. + guard selectedIndex != Int.max else { + return + } + + pagingViewController.select(index: selectedIndex, animated: true) + } +} diff --git a/README.md b/README.md index a24c93b5..3fa2471d 100644 --- a/README.md +++ b/README.md @@ -35,23 +35,128 @@ Parchment lets you page between view controllers while showing any type of gener ## Table of contents - [Getting started](#getting-started) - - [Basic usage](#basic-usage) - - [Data source](#data-source) - - [Infinite data source](#infinite-data-source) - - [Selecting items](#selecting-items) - - [Reloading data](#reloading-data) - - [Delegate](#delegate) - - [Size delegate](#size-delegate) - - [Selecting items](#selecting-items) -- [Customization](#customization) + - [SwiftUI](#basic-usage) + - [Basic usage](#basic-usage) + - [Dynamic pages](#dynamic-pages) + - [Update selection](#update-selection) + - [Modifiers](#modifiers) + - [UIKit](#basic-usage-with-uikit) + - [Basic usage with UIKit](#basic-usage-with-uikit) + - [Data source](#data-source) + - [Infinite data source](#infinite-data-source) + - [Selecting items](#selecting-items) + - [Reloading data](#reloading-data) + - [Delegate](#delegate) + - [Size delegate](#size-delegate) + - [Selecting items](#selecting-items) + - [Customization](#customization) +- [Options](#options) - [Installation](#installation) - [Changelog](#changelog) - [Licence](#licence) ## Getting started +Using UIKit? Go to [UIKit documentation](#basic-usage-with-uikit). + +
+ +SwiftUI + ### Basic usage +Create a `PageView` instance with the pages you want to show. Each `Page` takes a title and a content view, which can be any SwiftUI view. + +```swift +PageView { + Page("Title 0") { + Text("Page 0") + } + Page("Title 1") { + Text("Page 1") + } +} +``` + +By default, the menu items are displayed as titles, but you can also pass in any SwiftUI view as the menu item. The state parameter allows you to customize the menu item based on the selected state and scroll position of the view. For instance, you could show an icon that rotates based on its progress like this: + +```swift +PageView { + Page { state in + Image(systemName: "star.fill") + .rotationEffect(Angle(degrees: 90 * state.progress)) + } content: { + Text("Page 1") + } +} +``` + +### Dynamic pages + +To create a `PageView` with a dynamic number of pages, you can pass in a collection of items where each item is mapped to a `Page`: + +```swift +PageView(items, id: \.self) { item in + Page("Title \(item)") { + Text("Page \(item)") + } +} +``` + +### Update selection + +To select specific items, you can pass a binding into `PageView` with the index of the currently selected item. When updating the binding, Parchment will scroll to the new index. + +```swift +@State var selectedIndex: Int = 0 +... +PageView(selectedIndex: $selectedIndex) { + Page("Title 1") { + Button("Next") { + selectedIndex = 1 + } + } + Page("Title 2") { + Text("Page 2") + } +} +``` + +### Modifiers + +You can customize the `PageView` using the following modifiers. See [Options](#options) for more details on each option. + +```swift +PageView { + Page("Title 1") { + Text("Page 1") + } +} +.menuItemSize(.fixed(width: 100, height: 60)) +.menuItemSpacing(20) +.menuItemLabelSpacing(30) +.menuBackgroundColor(.white) +.menuInsets(.vertical, 20) +.menuHorizontalAlignment(.center) +.menuPosition(.bottom) +.menuTransition(.scrollAlongside) +.menuInteraction(.swipe) +.contentInteraction(.scrolling) +.contentNavigationOrientation(.vertical) +.selectedScrollPosition(.preferCentered) +.indicatorOptions(.visible(height: 4)) +.indicatorColor(.blue) +.borderOptions(.visible(height: 4)) +.borderColor(.blue.opacity(0.2)) +``` + +
+ +
+UIKit + +### Basic usage with UIKit + Parchment is built around the `PagingViewController` class. You can initialize it with an array of view controllers and it will display menu items for each view controller using their `title` property. ```Swift @@ -241,7 +346,7 @@ let pagingViewController = PagingViewController() pagingViewController.sizeDelegate = self ``` -## Customization +### Customization Parchment is built to be very flexible. The menu items are displayed using UICollectionView, so they can display pretty much whatever you want. If you need any further customization you can even subclass the collection view layout. All customization is handled by the properties listed below. @@ -266,6 +371,12 @@ pagingViewController.menuItemSize = .fixed(width: 40, height: 40) pagingViewController.menuItemSpacing = 10 ``` +See [Options](#options) for all customization options. + +
+ +## Options + #### `menuItemSize` The size of the menu items. When using [`sizeDelegate`](#size-delegate) the width will be ignored.