~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
115 lines · 4.4 KB Raw History
import SwiftUI

/// What a swipe between the profile's tabs writes on every frame.
///
/// Kept off `ProfileView`'s `@State` on purpose. Anything the screen's body
/// reads re-evaluates the whole screen when it changes, and a swipe changes
/// the offset a hundred times a second. Only `ProfileTabBar` and
/// `ProfilePagerHeight` read these, so those two are all a swipe re-evaluates.
@MainActor
@Observable
final class ProfilePagerState {
    /// Horizontal content offset of the pager.
    var offsetX: CGFloat = 0
    /// Width of one page; zero until laid out.
    var pageWidth: CGFloat = 0
    /// Measured height of each page's content.
    var pageHeights: [ProfileViewMode: CGFloat] = [:]
    /// Whether the tab bar has scrolled up past the top of the viewport, which
    /// decides whether a tab change scrolls back to it.
    var scrolledPastTop = false

    static let modes: [ProfileViewMode] = [.grid, .favorites, .stories]

    /// Position across the pages as a fraction: 0 is the first page, 1 the
    /// second. Falls back to the selected mode until the pager has a width.
    func progress(selected: ProfileViewMode) -> CGFloat {
        guard pageWidth > 0 else {
            return CGFloat(Self.modes.firstIndex(of: selected) ?? 0)
        }
        return max(0, min(offsetX / pageWidth, CGFloat(Self.modes.count - 1)))
    }

    func activeIndex(selected: ProfileViewMode) -> Int {
        Int(progress(selected: selected).rounded())
    }

    /// Height the pager should be at the current offset: the two pages the
    /// swipe is between, blended by how far it is between them.
    func interpolatedHeight(selected: ProfileViewMode) -> CGFloat {
        let fallback: CGFloat = 200
        let heights = Self.modes.map { pageHeights[$0] ?? fallback }
        let progress = progress(selected: selected)
        let lower = Int(progress.rounded(.down))
        let upper = min(lower + 1, Self.modes.count - 1)
        let fraction = progress - CGFloat(lower)
        return max(heights[lower] * (1 - fraction) + heights[upper] * fraction, fallback)
    }
}

/// The three tab buttons and the indicator that tracks the swipe beneath them.
struct ProfileTabBar: View {
    let pager: ProfilePagerState
    let selected: ProfileViewMode
    let onSelect: (ProfileViewMode) -> Void

    var body: some View {
        let modes = ProfilePagerState.modes
        let activeIndex = pager.activeIndex(selected: selected)

        HStack(spacing: 0) {
            ForEach(Array(modes.enumerated()), id: \.element) { index, mode in
                button(mode, isActive: index == activeIndex)
            }
        }
        .overlay(alignment: .bottomLeading) {
            GeometryReader { tabBarGeo in
                let tabWidth = tabBarGeo.size.width / CGFloat(modes.count)
                let indicatorWidth: CGFloat = 32
                let xOffset = pager.progress(selected: selected) * tabWidth + (tabWidth - indicatorWidth) / 2
                Rectangle()
                    .fill(Color.accentColor)
                    .frame(width: indicatorWidth, height: 2.5)
                    .offset(x: xOffset, y: -6)
            }
            .frame(height: 2.5)
            .allowsHitTesting(false)
        }
    }

    private func button(_ mode: ProfileViewMode, isActive: Bool) -> some View {
        Button {
            onSelect(mode)
        } label: {
            Image(systemName: isActive ? icon(for: mode) + ".fill" : icon(for: mode))
                .font(.system(size: 22))
                .foregroundStyle(isActive ? .primary : .secondary)
                .frame(maxWidth: .infinity)
                .padding(.top, 8)
                .padding(.bottom, 14)
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .accessibilityLabel("\(mode.rawValue.capitalized)")
    }

    private func icon(for mode: ProfileViewMode) -> String {
        switch mode {
        case .grid: "square.grid.3x3"
        case .favorites: "heart"
        case .stories: "clock"
        }
    }
}

/// Sizes the pager to the blend of its pages' heights at the current swipe
/// offset. A modifier rather than a `frame` in the parent's body so that the
/// per-frame read of the offset re-evaluates this, not the pages under it.
struct ProfilePagerHeight: ViewModifier {
    let pager: ProfilePagerState
    let selected: ProfileViewMode

    func body(content: Content) -> some View {
        content.frame(height: pager.interpolatedHeight(selected: selected), alignment: .top)
    }
}