From bee07e3a1928fd8e2db4abb15441e47e86b66d93 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 3 Sep 2026 01:28:57 -0400 Subject: [PATCH 1/2] Add the chrome primitives the new toolbars are built on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork shared by the Explore, Home and Product Details screens that follow. Nothing calls these yet. - `GlassToolbarModifier` (new): the Liquid Glass pill behind floating toolbar controls. It lives in Components rather than next to its first caller because Home, Explore and the category browser all need the same treatment. The near-transparent fill plus explicit `contentShape` are load-bearing — neither `glassEffect` nor `Material` claims the full pill for hit testing on its own, so without them taps near a control's edge fall through to the feed. - `View.enableSwipeBack()`: screens that hide the navigation bar to draw their own chrome also lose the interactive pop gesture, because the system gesture is owned by the back button they just hid. This re-enables it and gates it on having something to pop back to. - `UIImage.prefersLightToolbarIcons(displayedIn:)`: samples a 16x4 luminance grid from the strip of a photo that actually sits under the toolbar (after aspect-fill cropping and EXIF orientation flattening) so overlaid icons can pick black or white. It requires 78% of samples to be dark before going white, so mid-tone heroes stay on black instead of flickering between the two. - `BackButton` takes an optional `tint` for the same reason: over a photo hero the chevron can't be unconditionally black. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W --- Resell.xcodeproj/project.pbxproj | 4 + .../Extensions/UIImage + Extensions.swift | 93 +++++++++++++++++++ .../Utils/Extensions/View + Extensions.swift | 42 +++++++++ Resell/Views/Components/BackButton.swift | 26 ++++-- .../Components/GlassToolbarModifier.swift | 39 ++++++++ 5 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 Resell/Views/Components/GlassToolbarModifier.swift diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 923b1a6..b114d51 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ C6235EDD2FA1587E00395FD7 /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6235EDB2FA1587E00395FD7 /* SemanticVersion.swift */; }; C6235EDE2FA1587E00395FD7 /* AppVersionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6235EDA2FA1587E00395FD7 /* AppVersionService.swift */; }; C6F1BDBB2FA14CC1004886F8 /* ForceUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */; }; + ADDB8A9D2CC0738E02F4CFA5 /* GlassToolbarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -324,6 +325,7 @@ C6235EDA2FA1587E00395FD7 /* AppVersionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionService.swift; sourceTree = ""; }; C6235EDB2FA1587E00395FD7 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceUpdateView.swift; sourceTree = ""; }; + AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassToolbarModifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -598,6 +600,7 @@ 2C9B4D052C8FCAF20029DF61 /* Components */ = { isa = PBXGroup; children = ( + AD1309285CE00E73FFD36889 /* GlassToolbarModifier.swift */, C6F1BDBA2FA14CC1004886F8 /* ForceUpdateView.swift */, C607480A2F90643200825192 /* ShareSheet.swift */, 2EEAAB272F1ADA1B0006FF5C /* AvailabilitySettingsMenu.swift */, @@ -900,6 +903,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ADDB8A9D2CC0738E02F4CFA5 /* GlassToolbarModifier.swift in Sources */, 2E87F6FE2F29A651007C228E /* TransactionConfirmationPopup.swift in Sources */, 2E87F6FC2F270A13007C228E /* NotificationsSettingsView.swift in Sources */, 2E87F6FB2F270A0D007C228E /* NotificationsViewModel.swift in Sources */, diff --git a/Resell/Utils/Extensions/UIImage + Extensions.swift b/Resell/Utils/Extensions/UIImage + Extensions.swift index d5a14a6..098178d 100644 --- a/Resell/Utils/Extensions/UIImage + Extensions.swift +++ b/Resell/Utils/Extensions/UIImage + Extensions.swift @@ -78,6 +78,99 @@ extension UIImage { } return UIImage() }() + + /// Whether the toolbar band is *clearly* dark enough for white icons. + /// Mixed / mid-tone heroes (drinks, cafe walls, snow) stay on black. + func prefersLightToolbarIcons( + displayedIn containerSize: CGSize, + darkThreshold: CGFloat = 0.38, + requiredDarkFraction: CGFloat = 0.78 + ) -> Bool { + guard let samples = toolbarBandLuminanceSamples(in: containerSize), + !samples.isEmpty else { + return false + } + let darkCount = samples.filter { $0 < darkThreshold }.count + return CGFloat(darkCount) / CGFloat(samples.count) >= requiredDarkFraction + } + + /// Grid of relative luminances (0 = black, 1 = white) from the visible + /// top toolbar band after aspect-fill cropping and orientation fix. + func toolbarBandLuminanceSamples(in containerSize: CGSize) -> [CGFloat]? { + guard containerSize.width > 0, containerSize.height > 0, + let upright = flattenedOrientation().cgImage else { return nil } + + let imageSize = CGSize(width: upright.width, height: upright.height) + let visible = aspectFillVisibleRect(imageSize: imageSize, containerSize: containerSize) + // Cover both leading + trailing toolbar items with a short top strip. + let sample = CGRect( + x: visible.minX, + y: visible.minY, + width: max(1, visible.width), + height: max(1, visible.height * 0.14) + ).integral + + guard let cropped = upright.cropping(to: sample) else { return nil } + + let gridWidth = 16 + let gridHeight = 4 + let colorSpace = CGColorSpaceCreateDeviceRGB() + var pixels = [UInt8](repeating: 0, count: gridWidth * gridHeight * 4) + guard let context = CGContext( + data: &pixels, + width: gridWidth, + height: gridHeight, + bitsPerComponent: 8, + bytesPerRow: gridWidth * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + + context.interpolationQuality = .low + context.draw(cropped, in: CGRect(x: 0, y: 0, width: gridWidth, height: gridHeight)) + + return stride(from: 0, to: pixels.count, by: 4).map { offset in + let r = CGFloat(pixels[offset]) / 255 + let g = CGFloat(pixels[offset + 1]) / 255 + let b = CGFloat(pixels[offset + 2]) / 255 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + } + + /// Draw the image upright so EXIF orientation doesn't shift the sampled corner. + func flattenedOrientation() -> UIImage { + if imageOrientation == .up { return self } + let format = UIGraphicsImageRendererFormat.default() + format.scale = scale + format.opaque = false + return UIGraphicsImageRenderer(size: size, format: format).image { _ in + draw(in: CGRect(origin: .zero, size: size)) + } + } + + /// Pixel rect of `imageSize` that remains visible when aspect-filled into `containerSize`. + private func aspectFillVisibleRect(imageSize: CGSize, containerSize: CGSize) -> CGRect { + let imageAspect = imageSize.width / imageSize.height + let containerAspect = containerSize.width / containerSize.height + + if imageAspect > containerAspect { + let visibleWidth = imageSize.height * containerAspect + return CGRect( + x: (imageSize.width - visibleWidth) / 2, + y: 0, + width: visibleWidth, + height: imageSize.height + ) + } else { + let visibleHeight = imageSize.width / containerAspect + return CGRect( + x: 0, + y: (imageSize.height - visibleHeight) / 2, + width: imageSize.width, + height: visibleHeight + ) + } + } } diff --git a/Resell/Utils/Extensions/View + Extensions.swift b/Resell/Utils/Extensions/View + Extensions.swift index 92c1f85..5044ba0 100644 --- a/Resell/Utils/Extensions/View + Extensions.swift +++ b/Resell/Utils/Extensions/View + Extensions.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit extension View { @@ -18,6 +19,12 @@ extension View { func endEditingOnTap() -> some View { self.modifier(EndEditingOnTap()) } + + /// Restores the system edge-swipe-to-pop while a custom back button hides + /// the navigation bar item that normally owns that gesture. + func enableSwipeBack() -> some View { + background(InteractivePopGestureEnabler()) + } } struct EndEditingOnTap: ViewModifier { @@ -29,3 +36,38 @@ struct EndEditingOnTap: ViewModifier { } } +private struct InteractivePopGestureEnabler: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> InteractivePopGestureController { + InteractivePopGestureController() + } + + func updateUIViewController(_ uiViewController: InteractivePopGestureController, context: Context) {} +} + +private final class InteractivePopGestureController: UIViewController, UIGestureRecognizerDelegate { + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard let popGesture = navigationController?.interactivePopGestureRecognizer else { return } + popGesture.isEnabled = true + popGesture.delegate = self + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if navigationController?.interactivePopGestureRecognizer?.delegate === self { + navigationController?.interactivePopGestureRecognizer?.delegate = nil + } + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + (navigationController?.viewControllers.count ?? 0) > 1 + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + false + } +} + diff --git a/Resell/Views/Components/BackButton.swift b/Resell/Views/Components/BackButton.swift index 6155b27..4dedf1a 100644 --- a/Resell/Views/Components/BackButton.swift +++ b/Resell/Views/Components/BackButton.swift @@ -14,17 +14,21 @@ struct BackButton: View { // MARK: - Style enum Style { - /// Default: SF Symbol `chevron.left` at 17pt medium, black tint. + /// Default: SF Symbol `chevron.left` at 17pt medium. case systemChevron /// Resizable SF Symbol `chevron.left` with an explicit content size. case systemChevronResizable(width: CGFloat, height: CGFloat) - /// Asset image (e.g. `chevron.left`, `chevron.left.white`) with explicit size and tint. + /// Asset image (e.g. `chevron.left`, `chevron.left.white`) with explicit size. + /// `tint` on the style is used only when the view-level `tint` is nil. case assetChevron(name: String, size: CGSize, tint: Color = Constants.Colors.black) } // MARK: - Properties var style: Style = .systemChevron + /// Overrides the chevron color. Defaults to black for light chrome surfaces; + /// pass an adaptive color over photo heroes. + var tint: Color? = nil /// Size of the tappable rect surrounding the chevron. Defaults to 44x44 (Apple HIG minimum). /// Pass a smaller width when embedded in a custom HStack header that needs to preserve a narrower visual slot. var hitTargetSize: CGSize = CGSize(width: 44, height: 44) @@ -52,24 +56,34 @@ struct BackButton: View { .buttonStyle(.plain) } + private var resolvedTint: Color { + if let tint { return tint } + switch style { + case .assetChevron(_, _, let styleTint): + return styleTint + case .systemChevron, .systemChevronResizable: + return Constants.Colors.black + } + } + @ViewBuilder private var label: some View { switch style { case .systemChevron: Image(systemName: "chevron.left") .font(.system(size: 17, weight: .medium)) - .foregroundStyle(Constants.Colors.black) + .foregroundStyle(resolvedTint) case .systemChevronResizable(let width, let height): Image(systemName: "chevron.left") .resizable() .scaledToFit() .frame(width: width, height: height) - .foregroundStyle(Constants.Colors.black) - case .assetChevron(let name, let size, let tint): + .foregroundStyle(resolvedTint) + case .assetChevron(let name, let size, _): Image(name) .resizable() .frame(width: size.width, height: size.height) - .foregroundStyle(tint) + .foregroundStyle(resolvedTint) } } } diff --git a/Resell/Views/Components/GlassToolbarModifier.swift b/Resell/Views/Components/GlassToolbarModifier.swift new file mode 100644 index 0000000..5d0fa7d --- /dev/null +++ b/Resell/Views/Components/GlassToolbarModifier.swift @@ -0,0 +1,39 @@ +// +// GlassToolbarModifier.swift +// Resell +// +// Created by Andrew Gao on 9/3/26. +// + +import SwiftUI + +/// Liquid Glass background for the floating toolbar controls that sit over a +/// scrolling feed (search pill, filter button, notification button). +/// +/// The near-clear fill and explicit `contentShape` are load-bearing: neither +/// `glassEffect` nor `Material` alone claims the whole pill for hit testing, so +/// taps near the edge of a control would fall through to the content behind it. +struct GlassToolbarModifier: ViewModifier { + + var cornerRadius: CGFloat = 999 + /// Opaque enough to keep foreground text legible over busy content. + var isOpaque: Bool = false + + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + if #available(iOS 26, *) { + content + .background { shape.fill(Color.white.opacity(isOpaque ? 0.55 : 0.001)) } + .contentShape(shape) + .glassEffect(.regular, in: shape) + } else { + content + .background { shape.fill(Color.white.opacity(0.001)) } + .contentShape(shape) + .background( + isOpaque ? AnyShapeStyle(.regularMaterial) : AnyShapeStyle(.ultraThinMaterial), + in: shape + ) + } + } +} From 198722f899bbc0660e881978c9cc164105c8b7e9 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 30 Sep 2026 18:11:29 -0400 Subject: [PATCH 2/2] Expose GlassToolbarModifier through a View extension CLAUDE.md asks for reusable modifiers to be applied through a View extension (like .loadingView(isLoading:)) rather than .modifier(...) at each call site, so add .glassToolbarBackground(cornerRadius:isOpaque:). Also take the near-clear hit-testing fill from Constants.Colors.white. Co-Authored-By: Claude Opus 5.5 --- .../Views/Components/GlassToolbarModifier.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Resell/Views/Components/GlassToolbarModifier.swift b/Resell/Views/Components/GlassToolbarModifier.swift index 5d0fa7d..5bff876 100644 --- a/Resell/Views/Components/GlassToolbarModifier.swift +++ b/Resell/Views/Components/GlassToolbarModifier.swift @@ -23,12 +23,12 @@ struct GlassToolbarModifier: ViewModifier { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) if #available(iOS 26, *) { content - .background { shape.fill(Color.white.opacity(isOpaque ? 0.55 : 0.001)) } + .background { shape.fill(Constants.Colors.white.opacity(isOpaque ? 0.55 : 0.001)) } .contentShape(shape) .glassEffect(.regular, in: shape) } else { content - .background { shape.fill(Color.white.opacity(0.001)) } + .background { shape.fill(Constants.Colors.white.opacity(0.001)) } .contentShape(shape) .background( isOpaque ? AnyShapeStyle(.regularMaterial) : AnyShapeStyle(.ultraThinMaterial), @@ -37,3 +37,14 @@ struct GlassToolbarModifier: ViewModifier { } } } + +// MARK: - View Extension + +extension View { + + /// Gives a floating toolbar control a Liquid Glass background that also claims + /// its whole shape for hit testing. See `GlassToolbarModifier`. + func glassToolbarBackground(cornerRadius: CGFloat = 999, isOpaque: Bool = false) -> some View { + modifier(GlassToolbarModifier(cornerRadius: cornerRadius, isOpaque: isOpaque)) + } +}