From 33da4e0c42c357b9915fe1651fc10373deab0c9f Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 20 Aug 2026 11:43:25 +0100 Subject: [PATCH] fix: swiftui modifiers should be instance scoped --- platforms/swift/README.md | 2 + .../CheckoutViewController.swift | 63 +++++++++++--- .../CheckoutWebViewController.swift | 16 ++-- .../ShopifyCheckoutKit/ProgressBarView.swift | 6 +- .../CheckoutViewControllerTests.swift | 30 +++++++ .../SwiftUITests.swift | 87 ++++++++++++++----- 6 files changed, 161 insertions(+), 43 deletions(-) diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 53e7a0d2c..6149004a7 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -250,6 +250,8 @@ ShopifyCheckoutKit.configure { } ``` +SwiftUI modifiers such as `.appearance(...)`, `.tintColor(...)`, and `.title(...)` override these defaults only for that `ShopifyCheckout` value. They do not mutate `ShopifyCheckoutKit.configuration` or invalidate a cached preload. + | Option | Default | Purpose | | --- | --- | --- | | `appearance` | `.storefront` | Match the storefront's web checkout branding with a light color scheme, or use the Checkout Kit style with `.app(.automatic)`, `.app(.light)`, or `.app(.dark)`. | diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index dbc701305..1553f1ad2 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -7,14 +7,39 @@ import UIKit @MainActor public class CheckoutViewController: UINavigationController { public init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) { - let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: nil) + let rootViewController = CheckoutWebViewController( + checkoutURL: url, + configuration: ShopifyCheckoutKit.configuration, + delegate: delegate, + client: client, + entryPoint: nil + ) super.init(rootViewController: rootViewController) configureNavigationBar() presentationController?.delegate = rootViewController } package init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { - let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: entryPoint) + let rootViewController = CheckoutWebViewController( + checkoutURL: url, + configuration: ShopifyCheckoutKit.configuration, + delegate: delegate, + client: client, + entryPoint: entryPoint + ) + super.init(rootViewController: rootViewController) + configureNavigationBar() + presentationController?.delegate = rootViewController + } + + init(checkout url: URL, configuration: Configuration, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { + let rootViewController = CheckoutWebViewController( + checkoutURL: url, + configuration: configuration, + delegate: delegate, + client: client, + entryPoint: entryPoint + ) super.init(rootViewController: rootViewController) configureNavigationBar() presentationController?.delegate = rootViewController @@ -40,20 +65,26 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab public typealias UIViewControllerType = CheckoutViewController var checkoutURL: URL + var configuration: Configuration var client: (any CheckoutCommunicationProtocol)? var onDismissAction: (() -> Void)? var onFailAction: ((CheckoutError) -> Void)? public init(checkout url: URL) { checkoutURL = url + configuration = ShopifyCheckoutKit.configuration } var decoratedCheckoutURL: URL { - CheckoutURLDecorator.decorate(checkoutURL) + CheckoutURLDecorator.decorate(checkoutURL, configuration: configuration) } public func makeUIViewController(context _: Self.Context) -> CheckoutViewController { - let viewController = CheckoutViewController(checkout: decoratedCheckoutURL, client: client) + let viewController = CheckoutViewController( + checkout: decoratedCheckoutURL, + configuration: configuration, + client: client + ) configureWebViewController(viewController) return viewController } @@ -111,27 +142,31 @@ public protocol CheckoutConfigurable { extension CheckoutConfigurable { @discardableResult public func backgroundColor(_ color: UIColor) -> Self { - ShopifyCheckoutKit.configuration.backgroundColor = color - return self + modifyingConfiguration { $0.backgroundColor = color } } @discardableResult public func appearance(_ appearance: ShopifyCheckoutKit.Configuration.Appearance) -> Self { - ShopifyCheckoutKit.configuration.appearance = appearance - return self + modifyingConfiguration { $0.appearance = appearance } } @discardableResult public func tintColor(_ color: UIColor) -> Self { - ShopifyCheckoutKit.configuration.tintColor = color - return self + modifyingConfiguration { $0.tintColor = color } } @discardableResult public func title(_ title: String) -> Self { - ShopifyCheckoutKit.configuration.title = title - return self + modifyingConfiguration { $0.title = title } } @discardableResult public func closeButtonTintColor(_ color: UIColor?) -> Self { - ShopifyCheckoutKit.configuration.closeButtonTintColor = color - return self + modifyingConfiguration { $0.closeButtonTintColor = color } + } + + private func modifyingConfiguration(_ update: (inout Configuration) -> Void) -> Self { + guard var copy = self as? ShopifyCheckout else { + return self + } + + update(©.configuration) + return copy as? Self ?? self } } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index 1fd0c6a0b..6b831ba70 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -15,7 +15,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl var checkoutView: CheckoutWebView? lazy var progressBar: ProgressBarView = { - let progressBar = ProgressBarView(frame: .zero) + let progressBar = ProgressBarView(frame: .zero, tintColor: configuration.tintColor) progressBar.translatesAutoresizingMaskIntoConstraints = false return progressBar }() @@ -23,9 +23,10 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl var initialNavigation: Bool = true private let checkoutURL: URL + private let configuration: Configuration private lazy var closeBarButtonItem: UIBarButtonItem = { - if let closeButtonTintColor = ShopifyCheckoutKit.configuration.closeButtonTintColor { + if let closeButtonTintColor = configuration.closeButtonTintColor { var item: UIBarButtonItem if #available(iOS 26.0, *) { @@ -62,12 +63,15 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl // MARK: Initializers - public init(checkoutURL url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { + public init(checkoutURL url: URL, configuration: Configuration = ShopifyCheckoutKit.configuration, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { checkoutURL = url + self.configuration = configuration self.delegate = delegate self.client = client let checkoutView = CheckoutWebView.for(checkout: url, entryPoint: entryPoint) + checkoutView.backgroundColor = configuration.backgroundColor + checkoutView.underPageBackgroundColor = configuration.backgroundColor checkoutView.translatesAutoresizingMaskIntoConstraints = false checkoutView.scrollView.contentInsetAdjustmentBehavior = .automatic checkoutView.client = client @@ -75,13 +79,13 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl super.init(nibName: nil, bundle: nil) - title = ShopifyCheckoutKit.configuration.title + title = configuration.title navigationItem.rightBarButtonItem = closeBarButtonItem checkoutView.viewDelegate = self - view.backgroundColor = ShopifyCheckoutKit.configuration.backgroundColor + view.backgroundColor = configuration.backgroundColor } @available(*, unavailable) @@ -94,7 +98,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - view.backgroundColor = ShopifyCheckoutKit.configuration.backgroundColor + view.backgroundColor = configuration.backgroundColor } override public func viewDidLoad() { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift index c95136172..256afe2d8 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift @@ -10,8 +10,10 @@ class ProgressBarView: UIView { }() private var progressAnimation: UIViewPropertyAnimator? + private let configuredTintColor: UIColor - override init(frame: CGRect) { + init(frame: CGRect, tintColor: UIColor) { + configuredTintColor = tintColor super.init(frame: frame) addSubview(progressBar) @@ -21,7 +23,7 @@ class ProgressBarView: UIView { progressBar.heightAnchor.constraint(equalToConstant: 1) ]) - progressBar.tintColor = ShopifyCheckoutKit.configuration.tintColor + progressBar.tintColor = configuredTintColor } override func didMoveToSuperview() { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift index 8bfed0ceb..4aa9f61f4 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift @@ -56,6 +56,29 @@ class CheckoutViewDelegateTests: XCTestCase { XCTAssertEqual(viewController.title, "Custom title") } + func testInstanceConfigurationIsAppliedToCheckoutChrome() throws { + var configuration = Configuration() + configuration.backgroundColor = .red + configuration.tintColor = .blue + configuration.title = "Instance checkout" + configuration.closeButtonTintColor = .green + + let controller = MockCheckoutWebViewController( + checkoutURL: checkoutURL, + configuration: configuration + ) + controller.loadViewIfNeeded() + + XCTAssertEqual(controller.title, "Instance checkout") + assertColor(controller.view.backgroundColor, equals: .red) + assertColor(controller.checkoutView?.backgroundColor, equals: .red) + assertColor(controller.checkoutView?.underPageBackgroundColor, equals: .red) + assertColor(controller.progressBar.progressBar.tintColor, equals: .blue) + let closeButton = try XCTUnwrap(controller.navigationItem.rightBarButtonItem) + assertColor(closeButton.tintColor, equals: .green) + XCTAssertNotNil(closeButton.image) + } + func testCheckoutViewDidFailWithErrorDismissesViewController() { viewController.checkoutViewDidFailWithError(error: CheckoutError(code: .httpError, message: "error", httpStatusCode: 500)) @@ -125,6 +148,13 @@ class CheckoutViewDelegateTests: XCTestCase { let closeButton = controller.navigationItem.rightBarButtonItem XCTAssertNotNil(closeButton?.image) } + + private func assertColor(_ actual: UIColor?, equals expected: UIColor, file: StaticString = #filePath, line: UInt = #line) { + let actualComponents = actual?.cgColor.components + let expectedComponents = expected.cgColor.components + + XCTAssertEqual(actualComponents, expectedComponents, file: file, line: line) + } } @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift index 12b2b5909..92ef3372b 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift @@ -83,46 +83,91 @@ class CheckoutConfigurableTests: XCTestCase { try await super.tearDown() } - func testBackgroundColor() { + func testBackgroundColorIsCapturedWithoutChangingGlobalConfiguration() { + let globalColor = ShopifyCheckoutKit.configuration.backgroundColor let color = UIColor.red - shopifyCheckout.backgroundColor(color) - XCTAssertEqual(ShopifyCheckoutKit.configuration.backgroundColor, color) + + let sheet = shopifyCheckout.backgroundColor(color) + + XCTAssertEqual(sheet.configuration.backgroundColor, color) + XCTAssertEqual(shopifyCheckout.configuration.backgroundColor, globalColor) + XCTAssertEqual(ShopifyCheckoutKit.configuration.backgroundColor, globalColor) } - func testAppearance() { + func testAppearanceIsCapturedWithoutChangingGlobalConfiguration() { + let globalAppearance = ShopifyCheckoutKit.configuration.appearance let appearance = ShopifyCheckoutKit.Configuration.Appearance.app(.light) - shopifyCheckout.appearance(appearance) - XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, appearance) + + let sheet = shopifyCheckout.appearance(appearance) + + XCTAssertEqual(sheet.configuration.appearance, appearance) + XCTAssertEqual(shopifyCheckout.configuration.appearance, globalAppearance) + XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, globalAppearance) } - func testAppearanceDecoratesCheckoutURLAfterModifierRuns() throws { - let sheet = shopifyCheckout.appearance(.storefront) + func testAppearanceDecoratesCheckoutURLFromCapturedConfiguration() throws { + let sheet = shopifyCheckout.appearance(.app(.dark)) let items = try XCTUnwrap(URLComponents(url: sheet.decoratedCheckoutURL, resolvingAgainstBaseURL: false)?.queryItems) - XCTAssertEqual(items.first(where: { $0.name == "ec_color_scheme" })?.value, "light") - XCTAssertEqual(items.first(where: { $0.name == "ck_branding" })?.value, "shop") + XCTAssertEqual(items.first(where: { $0.name == "ec_color_scheme" })?.value, "dark") + XCTAssertEqual(items.first(where: { $0.name == "ck_branding" })?.value, "app") } - func testTintColor() { + func testTintColorIsCapturedWithoutChangingGlobalConfiguration() { + let globalColor = ShopifyCheckoutKit.configuration.tintColor let color = UIColor.blue - shopifyCheckout.tintColor(color) - XCTAssertEqual(ShopifyCheckoutKit.configuration.tintColor, color) + + let sheet = shopifyCheckout.tintColor(color) + + XCTAssertEqual(sheet.configuration.tintColor, color) + XCTAssertEqual(shopifyCheckout.configuration.tintColor, globalColor) + XCTAssertEqual(ShopifyCheckoutKit.configuration.tintColor, globalColor) } - func testTitle() { + func testTitleIsCapturedWithoutChangingGlobalConfiguration() { + let globalTitle = ShopifyCheckoutKit.configuration.title let title = "Test Title" - shopifyCheckout.title(title) - XCTAssertEqual(ShopifyCheckoutKit.configuration.title, title) + + let sheet = shopifyCheckout.title(title) + + XCTAssertEqual(sheet.configuration.title, title) + XCTAssertEqual(shopifyCheckout.configuration.title, globalTitle) + XCTAssertEqual(ShopifyCheckoutKit.configuration.title, globalTitle) } - func testCloseButtonTintColor() { + func testCloseButtonTintColorIsCapturedWithoutChangingGlobalConfiguration() { + let globalColor = ShopifyCheckoutKit.configuration.closeButtonTintColor let color = UIColor.green - shopifyCheckout.closeButtonTintColor(color) - XCTAssertEqual(ShopifyCheckoutKit.configuration.closeButtonTintColor, color) + + let sheet = shopifyCheckout.closeButtonTintColor(color) + + XCTAssertEqual(sheet.configuration.closeButtonTintColor, color) + XCTAssertEqual(shopifyCheckout.configuration.closeButtonTintColor, globalColor) + XCTAssertEqual(ShopifyCheckoutKit.configuration.closeButtonTintColor, globalColor) } - func testCloseButtonTintColorNil() { - shopifyCheckout.closeButtonTintColor(nil) + func testCloseButtonTintColorCanBeClearedOnInstance() { + let sheet = shopifyCheckout + .closeButtonTintColor(.green) + .closeButtonTintColor(nil) + + XCTAssertNil(sheet.configuration.closeButtonTintColor) XCTAssertNil(ShopifyCheckoutKit.configuration.closeButtonTintColor) } + + func testModifiersDoNotInvalidatePreload() async { + await Task.yield() + ShopifyCheckoutKit.preload(checkout: checkoutURL) + XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) + + _ = shopifyCheckout + .backgroundColor(.red) + .appearance(.app(.dark)) + .tintColor(.blue) + .title("Instance checkout") + .closeButtonTintColor(.green) + await Task.yield() + + XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) + } }