Created
May 25, 2026 23:58
-
-
Save timboudreau/780c2b6f91653cdb8c89332d11fbd90a to your computer and use it in GitHub Desktop.
Showing views with inter-view spacers - needs a little more Swift compiler jujitsu to eliminate the commas when using
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 | |
| /// Wraps some number of views of heterogenous types, without using something performance-killing like AnyView. | |
| fileprivate enum ViewTuple<A: View, B: View, C: View, D : View, E: View, F: View, G: View, H: View, I: View, J: View> { | |
| case None | |
| case Single(A) | |
| case Double(A, B) | |
| case Triple(A, B, C) | |
| case Quadruple(A, B, C, D) | |
| case Pentatuple(A, B, C, D, E) | |
| case Sextuple(A, B, C, D, E, F) | |
| case Septuple(A, B, C, D, E, F, G) | |
| case Octuple(A, B, C, D, E, F, G, H) | |
| case Nonuple(A, B, C, D, E, F, G, H, I) | |
| /// Get a menu (or whatever) view of the content | |
| @ViewBuilder func compactContent(_ caption : String) -> some View { | |
| Menu(caption) { | |
| switch self { | |
| case .None: EmptyView() | |
| case .Single(let a): a | |
| case .Double(let a, let b): | |
| a | |
| b | |
| case .Triple(let a, let b, let c): | |
| a | |
| b | |
| c | |
| case .Quadruple(let a, let b, let c, let d): | |
| a | |
| b | |
| c | |
| d | |
| case .Pentatuple(let a, let b, let c, let d, let e): | |
| a | |
| b | |
| c | |
| d | |
| e | |
| case .Sextuple(let a, let b, let c, let d, let e, let f): | |
| a | |
| b | |
| c | |
| d | |
| e | |
| f | |
| case .Septuple(let a, let b, let c, let d, let e, let f, let g): | |
| a | |
| b | |
| c | |
| d | |
| e | |
| f | |
| g | |
| case .Octuple(let a, let b, let c, let d, let e, let f, let g, let h): | |
| a | |
| b | |
| c | |
| d | |
| e | |
| f | |
| g | |
| h | |
| case .Nonuple(let a, let b, let c, let d, let e, let f, let g, let h, let i): | |
| a | |
| b | |
| c | |
| d | |
| e | |
| f | |
| g | |
| h | |
| i | |
| } | |
| } | |
| } | |
| } | |
| struct TupleView<A: View, B: View, C: View, D : View, E: View, F: View, G: View, H: View, I: View, J: View, Sep : View>: View { | |
| @Environment(\.menuText) private var menuText : MenuText | |
| @Environment(\.tupleViewSpacing) private var hspacing : CGFloat | |
| private let tuple: ViewTuple<A, B, C, D, E, F, G, H, I, J> | |
| private let separator : () -> Sep | |
| fileprivate init(tuple : ViewTuple<A, B, C, D, E, F, G, H, I, J>, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) { | |
| self.tuple = tuple | |
| self.separator = separator | |
| } | |
| func withSeparator<S: View>(@ViewBuilder _ sep : @escaping () -> S) -> TupleView<A, B, C, D, E, F, G, H, I, J, S> { | |
| .init(tuple: tuple, separator: sep) | |
| } | |
| init(@ViewBuilder _ a : () -> A, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where B == EmptyView, C == EmptyView, D == EmptyView, E == EmptyView, F == EmptyView, G == EmptyView, H == EmptyView, I == EmptyView, J == EmptyView { | |
| self.tuple = .Single(a()) | |
| self.separator = separator | |
| } | |
| // Why @autoclosure? The Swift compiler will *not* infer types through @ViewBuilder, but can through @autoclosure | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where C == EmptyView, D == EmptyView, E == EmptyView, F == EmptyView, G == EmptyView, H == EmptyView, I == EmptyView, J == EmptyView { | |
| self.tuple = .Double(a(), b()) | |
| self.separator = separator | |
| } | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, _ c : @autoclosure () -> C, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where D == EmptyView, E == EmptyView, F == EmptyView, G == EmptyView, H == EmptyView, I == EmptyView, J == EmptyView { | |
| self.tuple = .Triple(a(), b(), c()) | |
| self.separator = separator | |
| } | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, _ c : @autoclosure () -> C, _ d : @autoclosure () -> D, _ e : @autoclosure () -> E, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where E == EmptyView, F == EmptyView, G == EmptyView, H == EmptyView, I == EmptyView, J == EmptyView { | |
| self.tuple = .Quadruple(a(), b(), c(), d()) | |
| self.separator = separator | |
| } | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, _ c : @autoclosure () -> C, _ d : @autoclosure () -> D, _ e : @autoclosure () -> E, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where F == EmptyView, G == EmptyView, H == EmptyView, I == EmptyView, J == EmptyView { | |
| self.tuple = .Pentatuple(a(), b(), c(), d(), e()) | |
| self.separator = separator | |
| } | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, _ c : @autoclosure () -> C, _ d : @autoclosure () -> D, _ e : @autoclosure () -> E, _ f : @autoclosure () -> F, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where G == EmptyView, H == EmptyView, I == EmptyView, J == EmptyView { | |
| self.tuple = .Sextuple(a(), b(), c(), d(), e(), f()) | |
| self.separator = separator | |
| } | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, _ c : @autoclosure () -> C, _ d : @autoclosure () -> D, _ e : @autoclosure () -> E, _ f : @autoclosure () -> F, _ g : @autoclosure () -> G, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where H == EmptyView, I == EmptyView, J == EmptyView { | |
| self.tuple = .Septuple(a(), b(), c(), d(), e(), f(), g()) | |
| self.separator = separator | |
| } | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, _ c : @autoclosure () -> C, _ d : @autoclosure () -> D, _ e : @autoclosure () -> E, _ f : @autoclosure () -> F, _ g : @autoclosure () -> G, _ h : @autoclosure () -> H, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) where I == EmptyView, J == EmptyView { | |
| self.tuple = .Octuple(a(), b(), c(), d(), e(), f(), g(), h()) | |
| self.separator = separator | |
| } | |
| init(_ a : @autoclosure () -> A, _ b : @autoclosure () -> B, _ c : @autoclosure () -> C, _ d : @autoclosure () -> D, _ e : @autoclosure () -> E, _ f : @autoclosure () -> F, _ g : @autoclosure () -> G, _ h : @autoclosure () -> H, _ i : @autoclosure () -> I, @ViewBuilder separator: @escaping () -> Sep = { DotSeparator() }) { | |
| self.tuple = .Nonuple(a(), b(), c(), d(), e(), f(), g(), h(), i()) | |
| self.separator = separator | |
| } | |
| var body : some View { | |
| ViewThatFits { | |
| switch tuple { | |
| case .None: EmptyView() | |
| case .Single(let a): a | |
| case .Double(let a, let b): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| } | |
| .frame(maxWidth: .infinity) | |
| case .Triple(let a, let b, let c): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| separator() | |
| c.allowsTightening(true) | |
| } | |
| .frame(maxWidth: .infinity) | |
| case .Quadruple(let a, let b, let c, let d): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| separator() | |
| c.allowsTightening(true) | |
| separator() | |
| d.allowsTightening(true) | |
| } | |
| .frame(maxWidth: .infinity) | |
| case .Pentatuple(let a, let b, let c, let d, let e): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| separator() | |
| c.allowsTightening(true) | |
| separator() | |
| d.allowsTightening(true) | |
| separator() | |
| e.allowsTightening(true) | |
| } | |
| .frame(maxWidth: .infinity) | |
| case .Sextuple(let a, let b, let c, let d, let e, let f): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| separator() | |
| c.allowsTightening(true) | |
| separator() | |
| d.allowsTightening(true) | |
| separator() | |
| e.allowsTightening(true) | |
| separator() | |
| f.allowsTightening(true) | |
| } | |
| .frame(maxWidth: .infinity) | |
| case .Septuple(let a, let b, let c, let d, let e, let f, let g): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| separator() | |
| c.allowsTightening(true) | |
| separator() | |
| d.allowsTightening(true) | |
| separator() | |
| e.allowsTightening(true) | |
| separator() | |
| f.allowsTightening(true) | |
| separator() | |
| g.allowsTightening(true) | |
| }.frame(maxWidth: .infinity) | |
| case .Octuple(let a, let b, let c, let d, let e, let f, let g, let h): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| separator() | |
| c.allowsTightening(true) | |
| separator() | |
| d.allowsTightening(true) | |
| separator() | |
| e.allowsTightening(true) | |
| separator() | |
| f.allowsTightening(true) | |
| separator() | |
| g.allowsTightening(true) | |
| separator() | |
| h.allowsTightening(true) | |
| }.frame(maxWidth: .infinity) | |
| case .Nonuple(let a, let b, let c, let d, let e, let f, let g, let h, let i): | |
| HStack(spacing: self.hspacing) { | |
| a.allowsTightening(true) | |
| separator() | |
| b.allowsTightening(true) | |
| separator() | |
| c.allowsTightening(true) | |
| separator() | |
| d.allowsTightening(true) | |
| separator() | |
| e.allowsTightening(true) | |
| separator() | |
| f.allowsTightening(true) | |
| separator() | |
| g.allowsTightening(true) | |
| separator() | |
| h.allowsTightening(true) | |
| separator() | |
| i.allowsTightening(true) | |
| }.frame(maxWidth: .infinity) | |
| } | |
| // Alternate view - use a menu - ViewThatFits will pick this | |
| // if the area is too narrow | |
| tuple.compactContent(menuText.description) | |
| } | |
| } | |
| } | |
| /// 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) | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment