Created
May 28, 2026 18:25
-
-
Save nyteshade/12df01a338ef93723858ec023333c510 to your computer and use it in GitHub Desktop.
Alt Approach
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| /// A row of heterogeneous views separated by a configurable separator, | |
| /// collapsing into a `Menu` when the available width is too small to lay | |
| /// the items out side-by-side. | |
| /// | |
| /// Backed by Swift 5.9 parameter packs (Xcode 15, iOS 17, macOS 14), so | |
| /// any arity is supported without per-arity initializer overloads. | |
| struct TupleView<First: View, each Rest: View, Sep: View>: View { | |
| @Environment(\.menuText) private var menuText: MenuText | |
| @Environment(\.tupleViewSpacing) private var hspacing: CGFloat | |
| private let first: First | |
| private let rest: (repeat each Rest) | |
| private let separator: () -> Sep | |
| private let interleavedItems: [AnyView] // Cached interleaved array | |
| /// Creates a `TupleView` from one or more child views. | |
| /// | |
| /// - Parameters: | |
| /// - first: The leading view. No separator is drawn before it. | |
| /// - rest: Zero or more additional views; each is preceded by a | |
| /// separator in the laid-out `HStack`. | |
| /// - separator: View-builder producing the separator drawn between | |
| /// adjacent items. Defaults to `DotSeparator`. | |
| init( | |
| _ first: First, | |
| _ rest: repeat each Rest, | |
| @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }, | |
| ) { | |
| self.first = first | |
| self.rest = (repeat each rest) | |
| self.separator = separator | |
| self.interleavedItems = Self.interleave(first: first, rest: repeat each rest, separator: separator) | |
| } | |
| /// Returns a copy of this view using `sep` as the separator between items. | |
| /// | |
| /// The element types are preserved; only the separator type changes. | |
| func withSeparator<S: View>( | |
| @ViewBuilder _ sep: @escaping () -> S | |
| ) -> TupleView<First, repeat each Rest, S> { | |
| .init(first, repeat each rest, separator: sep) | |
| } | |
| var body: some View { | |
| ViewThatFits { | |
| HStack(spacing: hspacing) { | |
| // Render the pre‑built interleaved array | |
| ForEach(Array(interleavedItems.enumerated()), id: \.offset) { index, view in | |
| view | |
| } | |
| } | |
| .frame(maxWidth: .infinity) | |
| Menu(menuText.description) { | |
| ForEach(Array(interleavedItems.enumerated()), id: \.offset) { index, view in | |
| if index % 2 == 0 { | |
| view | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - Compile‑time array builder (recursive with parameter packs) | |
| private static func interleave<F: View, each R: View, S: View>( | |
| first: F, | |
| rest: repeat each R, | |
| separator: @escaping () -> S | |
| ) -> [AnyView] { | |
| var result = [AnyView(first)] | |
| repeat result.append(contentsOf: [AnyView(separator()), AnyView(each rest)]) | |
| return result | |
| } | |
| } | |
| /// Defines up the text used on the menu view of TupleViews that can't fit their width. | |
| struct MenuText : Sendable, Equatable, Hashable, Identifiable, CustomStringConvertible { | |
| let description : String | |
| var id : String { description } | |
| init(_ description: String) { | |
| self.description = description | |
| } | |
| } | |
| extension MenuText : ExpressibleByStringLiteral { | |
| public init(stringLiteral value: String) { | |
| self.description = value | |
| } | |
| } | |
| /// Looks up the text used on the menu view of TupleViews that can't fit their width. | |
| struct MenuTextKey : EnvironmentKey { | |
| static var defaultValue : MenuText = "Actions" | |
| } | |
| /// Looks up the spacing for the internal `HStack` in a TupleView | |
| struct TupleViewSpacing : EnvironmentKey { | |
| static var defaultValue : CGFloat = 8 | |
| } | |
| extension EnvironmentValues { | |
| var menuText: MenuText { | |
| get { | |
| self[MenuTextKey.self] | |
| } set { | |
| self[MenuTextKey.self] = newValue | |
| } | |
| } | |
| var tupleViewSpacing : CGFloat { | |
| get { | |
| self[TupleViewSpacing.self] | |
| } set { | |
| self[TupleViewSpacing.self] = newValue | |
| } | |
| } | |
| } | |
| /// Alternate vertical line separator for TupleView | |
| struct VerticalLineSeparator : View { | |
| @Environment(\.colorScheme) private var scheme : ColorScheme | |
| @Environment(\.dynamicTypeSize) private var typeSize : DynamicTypeSize | |
| var body : some View { | |
| Canvas { ctx, size in | |
| var p = Path(); | |
| p.move(to: CGPoint(x: round(size.width / 2), y : 0)) | |
| p.addLine(to: CGPoint(x : round(size.width / 2), y : size.height)) | |
| ctx.stroke(p, with: .color(scheme.theme.bandsViewActiveParameterLegend), style: scheme.theme.bandsViewIndicatorRule) | |
| }.frame(maxWidth: typeSize.separatorWidth, maxHeight: .infinity).fixedSize(horizontal: true, vertical: false) | |
| } | |
| } | |
| /// Default separator for TupleView | |
| struct DotSeparator : View { | |
| @Environment(\.colorScheme) private var scheme : ColorScheme | |
| @Environment(\.dynamicTypeSize) private var typeSize : DynamicTypeSize | |
| var body : some View { | |
| Canvas { ctx, size in | |
| let width : CGFloat = typeSize.dotWidth | |
| let rect = CGRect(x: -width / 2 + size.width / 2 , y: -width / 2 + size.height / 2, width: width, height: width) | |
| ctx.stroke(Circle().path(in: rect), with: .color(scheme.theme.bandsViewActiveParameterLegend), style: scheme.theme.bandsViewIndicatorRule) | |
| ctx.fill(Circle().path(in: rect), with: .color(scheme.theme.bandsViewActiveParameterLegend.opacity(0.75))) | |
| }.frame(maxWidth: typeSize.separatorWidth, maxHeight: .infinity).fixedSize(horizontal: true, vertical: false) | |
| } | |
| } | |
| fileprivate extension DynamicTypeSize { | |
| var separatorWidth : CGFloat { | |
| switch self { | |
| case .large: 14 | |
| case .medium: 12 | |
| case .small: 10 | |
| case .xSmall: 8 | |
| case .xLarge: 16 | |
| case .xxLarge: 18 | |
| case .xxxLarge: 20 | |
| default: 24 | |
| } | |
| } | |
| var dotWidth : CGFloat { | |
| self.separatorWidth * 0.125 | |
| } | |
| } | |
| // Borrowed from PluginLibs | |
| extension ColorScheme { | |
| var theme : Theme { .init() } | |
| } | |
| // Borrowed from PluginLibs | |
| struct Theme { | |
| let bandsViewActiveParameterLegend : Color = .orange | |
| let bandsViewIndicatorRule = StrokeStyle(lineWidth: 0.425, lineCap: .round, lineJoin: .round, miterLimit: 3.0, dash: [], dashPhase: 1.0) | |
| } | |
| struct TupleViewDemo : View { | |
| @State var whatever = false | |
| var body : some View { | |
| VStack { | |
| Text("Tuple View Demo").font(.title) | |
| TupleView( | |
| Button("Button A"){ | |
| print("A clicked") | |
| }, | |
| Button("Button B"){ | |
| print("B clicked") | |
| }, | |
| Button("Button C"){ | |
| print("C clicked") | |
| }, | |
| Text("Pwee!"), | |
| Button("Button D"){ | |
| print("D clicked") | |
| }, | |
| Button("Button E"){ | |
| print("E clicked") | |
| }, | |
| Toggle("Toogle F!", isOn: $whatever), | |
| Button("Button G"){ | |
| print("G clicked") | |
| }, | |
| ) | |
| .environment(\.tupleViewSpacing, 0) | |
| .buttonStyle(.borderless) | |
| .foregroundStyle(Color.accentColor) | |
| .frame(maxHeight: 24) | |
| .padding() | |
| Rectangle() | |
| .foregroundStyle(.black) | |
| .frame(idealWidth: .infinity, maxWidth: .infinity, idealHeight: .infinity, maxHeight: .infinity) | |
| .overlay { | |
| Text("This is a big black box.\n\nSome visualization goes here.").font(.title).bold().multilineTextAlignment(.center) | |
| } | |
| } | |
| } | |
| } | |
| #Preview { | |
| TupleViewDemo() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment