From 719c10981190ceb82c092241e75dabfe6f385655 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Wed, 7 Jul 2021 14:01:31 +0100 Subject: [PATCH] Support `spacing` property on `HStack`/`VStack` (#273) It's much easier to implement stack spacing when stacks are rendered as single-row or single-column grids, and grid gaps already work in all browsers. For this we need to slightly bump browser version requirements, most notably from Safari 11 to Safari 12. Resolves #272. * Remove unused properties in `StackDemo` * Implement stack spacing with grid gaps * Fix GTK build * Bump browser requirements in README.md * Remove outdated FIXME * Generalize snapshot timeouts * Prevent excessive CSS style leaks of properties --- .../TokamakDemo.xcodeproj/project.pbxproj | 8 +++ README.md | 6 +-- Sources/TokamakCore/Stubs/CGStubs.swift | 2 +- .../Views/Containers/List/List.swift | 4 +- .../Views/Containers/Section.swift | 2 +- Sources/TokamakCore/Views/Layout/HStack.swift | 14 ++++- Sources/TokamakCore/Views/Layout/VStack.swift | 12 ++++- Sources/TokamakDemo/StackDemo.swift | 40 +++++++++++++++ Sources/TokamakDemo/TokamakDemo.swift | 1 + Sources/TokamakGTK/Views/Stack.swift | 4 +- .../Resources/TokamakStyles.swift | 14 ++++- .../Views/Layout/HStack.swift | 9 ++-- .../Views/Layout/VStack.swift | 9 ++-- .../TokamakStaticHTMLTests/LayoutTests.swift | 48 +++++++++++++++++- .../HTMLTests/testOptional.1.txt | 15 ++++-- .../HTMLTests/testPaddingFusion.1.txt | 12 ++++- .../HTMLTests/testPaddingFusion.2.txt | 12 ++++- .../LayoutTests/testStacks.1.png | Bin 0 -> 1631 bytes .../LayoutTests/testStacks.2.png | Bin 0 -> 1762 bytes 19 files changed, 182 insertions(+), 30 deletions(-) create mode 100644 Sources/TokamakDemo/StackDemo.swift create mode 100644 Tests/TokamakStaticHTMLTests/__Snapshots__/LayoutTests/testStacks.1.png create mode 100644 Tests/TokamakStaticHTMLTests/__Snapshots__/LayoutTests/testStacks.2.png diff --git a/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj b/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj index bc3ea96ed..eb999bdcd 100644 --- a/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj +++ b/NativeDemo/TokamakDemo.xcodeproj/project.pbxproj @@ -49,6 +49,8 @@ B5F2BE042571443D00FB3653 /* PreferenceKeyDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F2BE022571443D00FB3653 /* PreferenceKeyDemo.swift */; }; D120FDDB257E7145008FFBAD /* TextEditorDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D120FDDA257E7145008FFBAD /* TextEditorDemo.swift */; }; D120FDDC257E7145008FFBAD /* TextEditorDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D120FDDA257E7145008FFBAD /* TextEditorDemo.swift */; }; + D1316F202500352200224A67 /* StackDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1316F1F2500352200224A67 /* StackDemo.swift */; }; + D1316F212500352200224A67 /* StackDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1316F1F2500352200224A67 /* StackDemo.swift */; }; D1B4229024B3B9BB00682F74 /* ListDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B4228E24B3B9BB00682F74 /* ListDemo.swift */; }; D1B4229124B3B9BB00682F74 /* ListDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B4228E24B3B9BB00682F74 /* ListDemo.swift */; }; D1B4229224B3B9BB00682F74 /* OutlineGroupDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B4228F24B3B9BB00682F74 /* OutlineGroupDemo.swift */; }; @@ -122,6 +124,7 @@ B5DBA22A24D509B4003D3347 /* RedactDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedactDemo.swift; sourceTree = ""; }; B5F2BE022571443D00FB3653 /* PreferenceKeyDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferenceKeyDemo.swift; sourceTree = ""; }; D120FDDA257E7145008FFBAD /* TextEditorDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextEditorDemo.swift; sourceTree = ""; }; + D1316F1F2500352200224A67 /* StackDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackDemo.swift; sourceTree = ""; }; D1B4228E24B3B9BB00682F74 /* ListDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListDemo.swift; sourceTree = ""; }; D1B4228F24B3B9BB00682F74 /* OutlineGroupDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutlineGroupDemo.swift; sourceTree = ""; }; D1C726F224CB63C6003B576D /* ButtonStyleDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ButtonStyleDemo.swift; sourceTree = ""; }; @@ -192,11 +195,13 @@ D1D6B62224D817350041E1D9 /* GeometryReaderDemo.swift */, D1C726F224CB63C6003B576D /* ButtonStyleDemo.swift */, B5C76E4924C73ED4003EABB2 /* AppStorageDemo.swift */, + D1C726F224CB63C6003B576D /* ButtonStyleDemo.swift */, B56F22DF24BC89FD001738DF /* ColorDemo.swift */, 85ED189E24AD425E0085DFA0 /* Counter.swift */, 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */, 85ED18A024AD425E0085DFA0 /* EnvironmentDemo.swift */, 85ED189C24AD425E0085DFA0 /* ForEachDemo.swift */, + D1D6B62224D817350041E1D9 /* GeometryReaderDemo.swift */, B56F22E224BD1C26001738DF /* GridDemo.swift */, D1B4228E24B3B9BB00682F74 /* ListDemo.swift */, D1B4228F24B3B9BB00682F74 /* OutlineGroupDemo.swift */, @@ -207,6 +212,7 @@ 3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */, 8500293E24D2FF3E001A2E84 /* SliderDemo.swift */, 85ED189A24AD425E0085DFA0 /* SpacerDemo.swift */, + D1316F1F2500352200224A67 /* StackDemo.swift */, 85ED189B24AD425E0085DFA0 /* TextDemo.swift */, 85ED189F24AD425E0085DFA0 /* TextFieldDemo.swift */, 85CBD5DE24B3BF090066468A /* ToggleDemo.swift */, @@ -369,6 +375,7 @@ D1EE7EA724C0DD2100C0D127 /* PickerDemo.swift in Sources */, D120FDDB257E7145008FFBAD /* TextEditorDemo.swift in Sources */, B5F2BE032571443D00FB3653 /* PreferenceKeyDemo.swift in Sources */, + D1316F202500352200224A67 /* StackDemo.swift in Sources */, 8500293F24D2FF3E001A2E84 /* SliderDemo.swift in Sources */, 4550BD5225B642B80088F4EA /* ShadowDemo.swift in Sources */, 85ED18A924AD425E0085DFA0 /* TokamakDemo.swift in Sources */, @@ -389,6 +396,7 @@ files = ( 85ED18AA24AD425E0085DFA0 /* TokamakDemo.swift in Sources */, 207C05712610E16E00BBBE54 /* DatePickerDemo.swift in Sources */, + D1316F212500352200224A67 /* StackDemo.swift in Sources */, B56F22E424BD1C26001738DF /* GridDemo.swift in Sources */, D1B4229324B3B9BB00682F74 /* OutlineGroupDemo.swift in Sources */, D1D6B62424D817350041E1D9 /* GeometryReaderDemo.swift in Sources */, diff --git a/README.md b/README.md index 3138a1f9c..cc9001598 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,9 @@ app. Any browser that [supports WebAssembly](https://caniuse.com/#feat=wasm) should work, which currently includes: - Edge 16+ -- Firefox 53+ -- Chrome 57+ -- (Mobile) Safari 11+ +- Firefox 61+ +- Chrome 66+ +- (Mobile) Safari 12+ Not all of these were tested though, compatibility reports are very welcome! diff --git a/Sources/TokamakCore/Stubs/CGStubs.swift b/Sources/TokamakCore/Stubs/CGStubs.swift index e303a54c2..67e03a5fd 100644 --- a/Sources/TokamakCore/Stubs/CGStubs.swift +++ b/Sources/TokamakCore/Stubs/CGStubs.swift @@ -15,8 +15,8 @@ // Created by Max Desiatov on 08/04/2020. // -import Foundation import CoreFoundation +import Foundation extension CGPoint { func rotate(_ angle: Angle, around origin: Self) -> Self { diff --git a/Sources/TokamakCore/Views/Containers/List/List.swift b/Sources/TokamakCore/Views/Containers/List/List.swift index ed8b12750..2c1de03f7 100644 --- a/Sources/TokamakCore/Views/Containers/List/List.swift +++ b/Sources/TokamakCore/Views/Containers/List/List.swift @@ -39,7 +39,7 @@ public struct List: View } var listStack: some View { - VStack(alignment: .leading) { () -> AnyView in + VStack(alignment: .leading, spacing: 0) { () -> AnyView in if let contentContainer = content as? ParentView { var sections = [AnyView]() var currentSection = [AnyView]() @@ -103,7 +103,7 @@ public enum _ListRow { @ViewBuilder rowView: @escaping (AnyView, Bool) -> RowView ) -> some View where RowView: View { ForEach(Array(children.enumerated()), id: \.offset) { offset, view in - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { HStack { Spacer() } rowView(view, offset == children.count - 1) } diff --git a/Sources/TokamakCore/Views/Containers/Section.swift b/Sources/TokamakCore/Views/Containers/Section.swift index 7d02dc620..2b8c6d27c 100644 --- a/Sources/TokamakCore/Views/Containers/Section.swift +++ b/Sources/TokamakCore/Views/Containers/Section.swift @@ -81,7 +81,7 @@ extension Section: View, SectionView where Parent: View, Content: View, Footer: func listRow(_ style: ListStyle) -> AnyView { AnyView( - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { headerView(style) sectionContent(style) footerView(style) diff --git a/Sources/TokamakCore/Views/Layout/HStack.swift b/Sources/TokamakCore/Views/Layout/HStack.swift index 1978734fb..edf11f2bc 100644 --- a/Sources/TokamakCore/Views/Layout/HStack.swift +++ b/Sources/TokamakCore/Views/Layout/HStack.swift @@ -24,6 +24,8 @@ public enum VerticalAlignment: Equatable { case bottom } +public let defaultStackSpacing: CGFloat = 8 + /// A view that arranges its children in a horizontal line. /// /// HStack { @@ -32,7 +34,7 @@ public enum VerticalAlignment: Equatable { /// } public struct HStack: _PrimitiveView where Content: View { public let alignment: VerticalAlignment - public let spacing: CGFloat? + let spacing: CGFloat public let content: Content public init( @@ -41,7 +43,7 @@ public struct HStack: _PrimitiveView where Content: View { @ViewBuilder content: () -> Content ) { self.alignment = alignment - self.spacing = spacing + self.spacing = spacing ?? defaultStackSpacing self.content = content() } } @@ -52,3 +54,11 @@ extension HStack: ParentView { (content as? GroupView)?.children ?? [AnyView(content)] } } + +public struct _HStackProxy where Content: View { + public let subject: HStack + + public init(_ subject: HStack) { self.subject = subject } + + public var spacing: CGFloat { subject.spacing } +} diff --git a/Sources/TokamakCore/Views/Layout/VStack.swift b/Sources/TokamakCore/Views/Layout/VStack.swift index ff61d3470..81d287325 100644 --- a/Sources/TokamakCore/Views/Layout/VStack.swift +++ b/Sources/TokamakCore/Views/Layout/VStack.swift @@ -29,7 +29,7 @@ public enum HorizontalAlignment: Equatable { /// } public struct VStack: _PrimitiveView where Content: View { public let alignment: HorizontalAlignment - public let spacing: CGFloat? + let spacing: CGFloat public let content: Content public init( @@ -38,7 +38,7 @@ public struct VStack: _PrimitiveView where Content: View { @ViewBuilder content: () -> Content ) { self.alignment = alignment - self.spacing = spacing + self.spacing = spacing ?? defaultStackSpacing self.content = content() } } @@ -49,3 +49,11 @@ extension VStack: ParentView { (content as? GroupView)?.children ?? [AnyView(content)] } } + +public struct _VStackProxy where Content: View { + public let subject: VStack + + public init(_ subject: VStack) { self.subject = subject } + + public var spacing: CGFloat { subject.spacing } +} diff --git a/Sources/TokamakDemo/StackDemo.swift b/Sources/TokamakDemo/StackDemo.swift new file mode 100644 index 000000000..45bc7cf69 --- /dev/null +++ b/Sources/TokamakDemo/StackDemo.swift @@ -0,0 +1,40 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import TokamakShim + +struct StackDemo: View { + @State private var horizontalSpacing: CGFloat = 8 + @State private var verticalSpacing: CGFloat = 8 + + var body: some View { + VStack(spacing: verticalSpacing) { + Text("Horizontal Spacing") + Slider(value: $horizontalSpacing, in: 0...100) + + Text("Vertical Spacing") + Slider(value: $verticalSpacing, in: 0...100) + HStack(spacing: horizontalSpacing) { + Rectangle() + .fill(Color.red) + .frame(width: 100, height: 100) + + Rectangle() + .fill(Color.green) + .frame(width: 100, height: 100) + } + } + } +} diff --git a/Sources/TokamakDemo/TokamakDemo.swift b/Sources/TokamakDemo/TokamakDemo.swift index 1da5e69bf..76d198a3f 100644 --- a/Sources/TokamakDemo/TokamakDemo.swift +++ b/Sources/TokamakDemo/TokamakDemo.swift @@ -106,6 +106,7 @@ struct TokamakDemoView: View { } } Section(header: Text("Layout")) { + NavItem("HStack/VStack", destination: StackDemo()) if #available(OSX 10.16, iOS 14.0, *) { NavItem("Grid", destination: GridDemo()) } else { diff --git a/Sources/TokamakGTK/Views/Stack.swift b/Sources/TokamakGTK/Views/Stack.swift index 176c55b5a..d2fc4af1a 100644 --- a/Sources/TokamakGTK/Views/Stack.swift +++ b/Sources/TokamakGTK/Views/Stack.swift @@ -59,7 +59,7 @@ extension VStack: GTKPrimitive { Box( content: content, orientation: GTK_ORIENTATION_VERTICAL, - spacing: spacing ?? 8, + spacing: _VStackProxy(self).spacing, alignment: .init(horizontal: alignment, vertical: .center) ) ) @@ -73,7 +73,7 @@ extension HStack: GTKPrimitive { Box( content: content, orientation: GTK_ORIENTATION_HORIZONTAL, - spacing: spacing ?? 8, + spacing: _HStackProxy(self).spacing, alignment: .init(horizontal: .center, vertical: alignment) ) ) diff --git a/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift b/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift index 0c8204770..11d34fab3 100644 --- a/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift +++ b/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift @@ -12,9 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. +import TokamakCore + public let tokamakStyles = """ -._tokamak-stack > * { - flex-shrink: 0; +._tokamak-stack { + display: grid; +} +._tokamak-hstack { + grid-auto-flow: column; + column-gap: var(--tokamak-stack-gap, \(Int(defaultStackSpacing))px); +} +._tokamak-vstack { + grid-auto-flow: row; + row-gap: var(--tokamak-stack-gap, \(Int(defaultStackSpacing))px); } ._tokamak-scrollview-hideindicators { scrollbar-color: transparent; diff --git a/Sources/TokamakStaticHTML/Views/Layout/HStack.swift b/Sources/TokamakStaticHTML/Views/Layout/HStack.swift index 484577554..3402bbbd3 100644 --- a/Sources/TokamakStaticHTML/Views/Layout/HStack.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/HStack.swift @@ -32,13 +32,16 @@ extension HStack: _HTMLPrimitive, SpacerContainer { @_spi(TokamakStaticHTML) public var renderedBody: AnyView { - AnyView(HTML("div", [ + let spacing = _HStackProxy(self).spacing + + return AnyView(HTML("div", [ "style": """ - display: flex; flex-direction: row; align-items: \(alignment.cssValue); + align-items: \(alignment.cssValue); \(hasSpacer ? "width: 100%;" : "") \(fillCrossAxis ? "height: 100%;" : "") + \(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px" : "") """, - "class": "_tokamak-stack", + "class": "_tokamak-stack _tokamak-hstack", ]) { content }) } } diff --git a/Sources/TokamakStaticHTML/Views/Layout/VStack.swift b/Sources/TokamakStaticHTML/Views/Layout/VStack.swift index af0bdec00..badb89f30 100644 --- a/Sources/TokamakStaticHTML/Views/Layout/VStack.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/VStack.swift @@ -32,13 +32,16 @@ extension VStack: _HTMLPrimitive, SpacerContainer { @_spi(TokamakStaticHTML) public var renderedBody: AnyView { - AnyView(HTML("div", [ + let spacing = _VStackProxy(self).spacing + + return AnyView(HTML("div", [ "style": """ - display: flex; flex-direction: column; align-items: \(alignment.cssValue); + justify-items: \(alignment.cssValue); \(hasSpacer ? "height: 100%;" : "") \(fillCrossAxis ? "width: 100%;" : "") + \(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px" : "") """, - "class": "_tokamak-stack", + "class": "_tokamak-stack _tokamak-vstack", ]) { content }) } } diff --git a/Tests/TokamakStaticHTMLTests/LayoutTests.swift b/Tests/TokamakStaticHTMLTests/LayoutTests.swift index 8693d80a6..f2b041985 100644 --- a/Tests/TokamakStaticHTMLTests/LayoutTests.swift +++ b/Tests/TokamakStaticHTMLTests/LayoutTests.swift @@ -79,12 +79,42 @@ struct Star: Shape { } } +struct Stacks: View { + let spacing: CGFloat + + var body: some View { + VStack(spacing: spacing) { + HStack(spacing: spacing) { + Rectangle() + .fill(Color.red) + .frame(width: 100, height: 100) + + Rectangle() + .fill(Color.green) + .frame(width: 100, height: 100) + } + + HStack(spacing: spacing) { + Rectangle() + .fill(Color.blue) + .frame(width: 100, height: 100) + + Rectangle() + .fill(Color.black) + .frame(width: 100, height: 100) + } + } + } +} + +private let defaultSnapshotTimeout: TimeInterval = 10 + final class LayoutTests: XCTestCase { func testPath() { assertSnapshot( matching: Star().fill(Color(red: 1, green: 0.75, blue: 0.1, opacity: 1)), as: .image(size: .init(width: 100, height: 100)), - timeout: 10 + timeout: defaultSnapshotTimeout ) } @@ -92,7 +122,21 @@ final class LayoutTests: XCTestCase { assertSnapshot( matching: Circle().stroke(Color.green).frame(width: 100, height: 100, alignment: .center), as: .image(size: .init(width: 150, height: 150)), - timeout: 10 + timeout: defaultSnapshotTimeout + ) + } + + func testStacks() { + assertSnapshot( + matching: Stacks(spacing: 10), + as: .image(size: .init(width: 210, height: 210)), + timeout: defaultSnapshotTimeout + ) + + assertSnapshot( + matching: Stacks(spacing: 20), + as: .image(size: .init(width: 220, height: 220)), + timeout: defaultSnapshotTimeout ) } } diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt index 9d085442a..14bd3069a 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt @@ -2,8 +2,16 @@