Skip to content

Commit

Permalink
Fix issues with SwiftUI views being reset when updating
Browse files Browse the repository at this point in the history
The current implementation calls reloadData on each update, which
re-creates the entire content view and resets any state (like
scrolling). To prevent this, we update the existing
UIHostingController if the current page is the same.

This requires us to have stable identifiers, to know if the current
page is the same as before or a new one. For title-based pages, we
just use the title as the identifier. For pages with custom SwiftUI
headers, we default to using the index of the view as the
identifier. This is the same behaviour that we have today, although it
will only work when having static pages. In order to support dynamic
pages with custom SwiftUI headers, we introduce a new initializer that
allows specifying the identifier.
  • Loading branch information
rechsteiner committed Feb 17, 2024
1 parent ecbfc1a commit 279c935
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 10 deletions.
1 change: 1 addition & 0 deletions ExampleSwiftUI/ExampleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct ExampleApp: App {
NavigationLink("Change items", destination: ChangeItemsView())
NavigationLink("Dynamic items", destination: DynamicItemsView())
NavigationLink("Custom indicator", destination: CustomIndicatorView())
NavigationLink("Scrolling Views", destination: ScrollingView())
}
}
.navigationBarTitleDisplayMode(.inline)
Expand Down
31 changes: 31 additions & 0 deletions ExampleSwiftUI/ScrollingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Parchment
import SwiftUI
import UIKit

struct ScrollingView: View {
var body: some View {
PageView {
Page("First") {
ScrollingContentView()
}
Page("Second") {
ScrollingContentView()
}
Page("Third") {
ScrollingContentView()
}
}
}
}

struct ScrollingContentView: View {
var body: some View {
List {
ForEach(0...50 , id: \.self) { item in
NavigationLink(destination: Text("\(item)")) {
Text("\(item)")
}
}
}
}
}
4 changes: 4 additions & 0 deletions Parchment.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
952D802F1E37CC09003DCB18 /* PagingTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 952D802E1E37CC09003DCB18 /* PagingTransition.swift */; };
9530E25329DEC2E5004FC88C /* PageContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9530E25229DEC2E5004FC88C /* PageContentConfiguration.swift */; };
953B8D352416C3DC0047BBA1 /* SelfSizingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 953B8D342416C3DC0047BBA1 /* SelfSizingViewController.swift */; };
95428A562B80F6EA00D61143 /* ScrollingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95428A552B80F6EA00D61143 /* ScrollingView.swift */; };
9546B2AB2A1D2F06000390C6 /* PagingHostingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9546B2AA2A1D2F06000390C6 /* PagingHostingIndicatorView.swift */; };
9546B2AD2A1D44EF000390C6 /* CustomIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9546B2AC2A1D44EF000390C6 /* CustomIndicatorView.swift */; };
9546B2AF2A1D4767000390C6 /* PagingIndicatorStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9546B2AE2A1D4767000390C6 /* PagingIndicatorStyle.swift */; };
Expand Down Expand Up @@ -269,6 +270,7 @@
952D802E1E37CC09003DCB18 /* PagingTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagingTransition.swift; sourceTree = "<group>"; };
9530E25229DEC2E5004FC88C /* PageContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentConfiguration.swift; sourceTree = "<group>"; };
953B8D342416C3DC0047BBA1 /* SelfSizingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfSizingViewController.swift; sourceTree = "<group>"; };
95428A552B80F6EA00D61143 /* ScrollingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingView.swift; sourceTree = "<group>"; };
9546B2AA2A1D2F06000390C6 /* PagingHostingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingHostingIndicatorView.swift; sourceTree = "<group>"; };
9546B2AC2A1D44EF000390C6 /* CustomIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomIndicatorView.swift; sourceTree = "<group>"; };
9546B2AE2A1D4767000390C6 /* PagingIndicatorStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagingIndicatorStyle.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -776,6 +778,7 @@
95D2AE51242BCC9500AC3D46 /* ExampleApp.swift */,
95D2AE55242BCC9500AC3D46 /* DefaultView.swift */,
956FFFCB29BE1FD100477E94 /* ChangeItemsView.swift */,
95428A552B80F6EA00D61143 /* ScrollingView.swift */,
956FFFD129BE273B00477E94 /* CustomizedView.swift */,
95F83D822623804F003B728F /* DynamicItemsView.swift */,
956F000929CCFC6C00477E94 /* InterpolatedView.swift */,
Expand Down Expand Up @@ -1194,6 +1197,7 @@
956FFFD229BE273B00477E94 /* CustomizedView.swift in Sources */,
95D2AE52242BCC9500AC3D46 /* ExampleApp.swift in Sources */,
956F000A29CCFC6C00477E94 /* InterpolatedView.swift in Sources */,
95428A562B80F6EA00D61143 /* ScrollingView.swift in Sources */,
956FFFCC29BE1FD100477E94 /* ChangeItemsView.swift in Sources */,
95F83D6426237D2B003B728F /* SelectedIndexView.swift in Sources */,
95D2AE56242BCC9500AC3D46 /* DefaultView.swift in Sources */,
Expand Down
36 changes: 29 additions & 7 deletions Parchment/Classes/PageViewCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import UIKit

@available(iOS 14.0, *)
final class PageViewCoordinator: PagingViewControllerDataSource, PagingViewControllerDelegate {
final class WeakReference<T: AnyObject> {
weak var value: T?

init(value: T) {
self.value = value
}
}

var parent: PagingControllerRepresentableView
var controllers: [Int: WeakReference<UIViewController>] = [:]

init(_ pagingController: PagingControllerRepresentableView) {
parent = pagingController
Expand All @@ -17,14 +26,14 @@ final class PageViewCoordinator: PagingViewControllerDataSource, PagingViewContr
viewControllerAt index: Int
) -> UIViewController {
let item = parent.items[index]
var hostingViewController: UIViewController
let hostingViewController: UIViewController

if let item = item as? PageItem {
hostingViewController = item.page.content()
} else if let content = parent.content {
hostingViewController = content(item)
if let controller = controllers[item.identifier]?.value {
hostingViewController = controller
} else {
hostingViewController = UIViewController()
let controller = hostingController(for: item)
controllers[item.identifier] = WeakReference(value: controller)
hostingViewController = controller
}

let backgroundColor = parent.options.pagingContentBackgroundColor
Expand Down Expand Up @@ -52,7 +61,6 @@ final class PageViewCoordinator: PagingViewControllerDataSource, PagingViewContr
}

parent.onDidScroll?(pagingItem)

}

func pagingViewController(
Expand All @@ -70,4 +78,18 @@ final class PageViewCoordinator: PagingViewControllerDataSource, PagingViewContr
) {
parent.onDidSelect?(pagingItem)
}

private func hostingController(for pagingItem: PagingItem) -> UIViewController {
var hostingViewController: UIViewController
if let item = pagingItem as? PageItem {
hostingViewController = item.page.content()
} else {
assertionFailure("""
PageItem is required when using the SwiftUI wrappers.
Please report if you somehow ended up here.
""")
hostingViewController = UIViewController()
}
return hostingViewController
}
}
73 changes: 73 additions & 0 deletions Parchment/Structs/Page.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import SwiftUI
@available(iOS 14.0, *)
public struct Page {
let reuseIdentifier: String
let pageIdentifier: String?
let header: (PagingOptions, PageState) -> UIContentConfiguration
let content: () -> UIViewController
let update: (UIViewController) -> Void

/// Creates a new page with the given header and content views.
///
Expand All @@ -49,6 +51,61 @@ public struct Page {
let content = content()

self.reuseIdentifier = "CellIdentifier-\(String(describing: Header.self))"
self.pageIdentifier = nil

self.header = { options, state in
if #available(iOS 16.0, *) {
return UIHostingConfiguration {
PageCustomView(
content: header(state),
options: options,
state: state
)
}
.margins(.all, 0)
} else {
return PageContentConfiguration {
PageCustomView(
content: header(state),
options: options,
state: state
)
}
.margins(.all, 0)
}
}
self.content = {
UIHostingController(rootView: content)
}
self.update = { viewController in
let hostingController = viewController as! UIHostingController<Content>
hostingController.rootView = content
}
}

/// Creates a new page with the given header and content views.
///
/// - Parameters:
/// - id: A unique identifier for this page.
/// - 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<Header: View, Content: View, Id: LosslessStringConvertible>(
id: Id,
@ViewBuilder header: @escaping (PageState) -> Header,
@ViewBuilder content: () -> Content
) {
let content = content()

self.reuseIdentifier = "CellIdentifier-\(String(describing: Header.self))"
self.pageIdentifier = id.description

self.header = { options, state in
if #available(iOS 16.0, *) {
return UIHostingConfiguration {
Expand All @@ -73,6 +130,10 @@ public struct Page {
self.content = {
UIHostingController(rootView: content)
}
self.update = { viewController in
let hostingController = viewController as! UIHostingController<Content>
hostingController.rootView = content
}
}

/// Creates a new page with the given localized title and content views.
Expand All @@ -92,6 +153,8 @@ public struct Page {
let content = content()

self.reuseIdentifier = "CellIdentifier-PageTitleView"
self.pageIdentifier = "PageIdentifier-\(titleKey)"

self.header = { options, state in
if #available(iOS 16.0, *) {
return UIHostingConfiguration {
Expand All @@ -118,6 +181,10 @@ public struct Page {
self.content = {
UIHostingController(rootView: content)
}
self.update = { viewController in
let hostingController = viewController as! UIHostingController<Content>
hostingController.rootView = content
}
}

/// Creates a new page with the given title and content views.
Expand All @@ -137,6 +204,8 @@ public struct Page {
let content = content()

self.reuseIdentifier = "CellIdentifier-PageTitleView"
self.pageIdentifier = "PageIdentifier-\(title)"

self.header = { options, state in
if #available(iOS 16.0, *) {
return UIHostingConfiguration {
Expand All @@ -163,6 +232,10 @@ public struct Page {
self.content = {
UIHostingController(rootView: content)
}
self.update = { viewController in
let hostingController = viewController as! UIHostingController<Content>
hostingController.rootView = content
}
}
}

Expand Down
3 changes: 1 addition & 2 deletions Parchment/Structs/PageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,8 @@ public struct PageView: View {
self.items = content()
.enumerated()
.map { (index, page) in
// TODO: What should we use as the identifier?
PageItem(
identifier: index,
identifier: page.pageIdentifier?.hashValue ?? index,
index: index,
page: page
)
Expand Down
26 changes: 25 additions & 1 deletion Parchment/Structs/PagingControllerRepresentableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,37 @@ struct PagingControllerRepresentableView: UIViewControllerRepresentable {
_ pagingViewController: PagingViewController,
context: UIViewControllerRepresentableContext<PagingControllerRepresentableView>
) {
var oldItems: [Int: PagingItem] = [:]

for oldItem in context.coordinator.parent.items {
if let oldItem = oldItem as? PageItem {
oldItems[oldItem.identifier] = oldItem
}
}

context.coordinator.parent = self

if pagingViewController.dataSource == nil {
pagingViewController.dataSource = context.coordinator
}

pagingViewController.reloadData()
// We only want to reload the content views when the items have actually
// changed. For items that are added, a new view controller instance will
// be created by the PageViewCoordinator.
if let currentItem = pagingViewController.state.currentPagingItem,
let pageItem = currentItem as? PageItem,
let oldItem = oldItems[pageItem.identifier] {
pagingViewController.reloadMenu()

if !oldItem.isEqual(to: currentItem) {
if let pageItem = currentItem as? PageItem,
let viewController = context.coordinator.controllers[currentItem.identifier]?.value {
pageItem.page.update(viewController)
}
}
} else {
pagingViewController.reloadData()
}

// HACK: If the user don't pass a selectedIndex binding, the
// default parameter is set to .constant(Int.max) which allows
Expand Down

0 comments on commit 279c935

Please sign in to comment.