Swift Charts: Prevent horizontal bar marks from overlapping Y axis label - swiftui

Pretty basic code for a horizontal bar chart using Apple's Swift Charts:
private var testData: [(String, Int)] = []
init() {
for i in (0 ..< 10).reversed() {
testData.append(
("Item\(i)", i + 3)
)
}
}
var body: some View {
Chart(testData, id: \.0) { item in
BarMark(
x: .value("x", item.1),
y: .value("y", item.0)
)
.foregroundStyle(Color.red)
.annotation(
position: .overlay,
alignment: .trailing
) {
Text("\(item.1)")
.foregroundColor(.white)
.fontWeight(.bold)
.padding(.trailing)
}
}
.chartXAxis(.hidden)
.chartYAxis {
AxisMarks(
position: .trailing
) { value in
AxisValueLabel(centered: true) {
if let stringValue = value.as(String.self) {
Text(stringValue)
.font(.title2)
}
}
}
}
}
With this result but I can't seem to prevent the chart from overlapping the Y axis label:
Even if I align the label with position: .leading it's still hidden under the chart. Any help would be appreciated.

Related

Is it possible to make dynamically VStack in the HStack when screen width ends [duplicate]

Is it possible that the blue tags (which are currently truncated) are displayed completely and then it automatically makes a line break?
NavigationLink(destination: GameListView()) {
VStack(alignment: .leading, spacing: 5){
// Name der Sammlung:
Text(collection.name)
.font(.headline)
// Optional: Für welche Konsolen bzw. Plattformen:
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
.lineLimit(1)
}
}
}
.padding(.vertical, 10)
}
Also, there should be no line breaks with in the blue tags:
That's how it should look in the end:
Here is some approach of how this could be done using alignmentGuide(s). It is simplified to avoid many code post, but hope it is useful.
Update: There is also updated & improved variant of below solution in my answer for SwiftUI HStack with wrap and dynamic height
This is the result:
And here is full demo code (orientation is supported automatically):
import SwiftUI
struct TestWrappedLayout: View {
#State var platforms = ["Ninetendo", "XBox", "PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4"]
var body: some View {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
private func generateContent(in g: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.platforms, id: \.self) { platform in
self.item(for: platform)
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if platform == self.platforms.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if platform == self.platforms.last! {
height = 0 // last item
}
return result
})
}
}
}
func item(for text: String) -> some View {
Text(text)
.padding(.all, 5)
.font(.body)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}
struct TestWrappedLayout_Previews: PreviewProvider {
static var previews: some View {
TestWrappedLayout()
}
}
For me, none of the answers worked. Either because I had different types of elements or because elements around were not being positioned correctly. Therefore, I ended up implementing my own WrappingHStack which can be used in a very similar way to HStack. You can find it at GitHub: WrappingHStack.
Here is an example:
Code:
WrappingHStack {
Text("WrappingHStack")
.padding()
.font(.title)
.border(Color.black)
Text("can handle different element types")
Image(systemName: "scribble")
.font(.title)
.frame(width: 200, height: 20)
.background(Color.purple)
Text("and loop")
.bold()
WrappingHStack(1...20, id:\.self) {
Text("Item: \($0)")
.padding(3)
.background(Rectangle().stroke())
}.frame(minWidth: 250)
}
.padding()
.border(Color.black)
I've had ago at creating what you need.
Ive used HStack's in a VStack.
You pass in a geometryProxy which is used for determining the maximum row width.
I went with passing this in so it would be usable within a scrollView
I wrapped the SwiftUI Views in a UIHostingController to get a size for each child.
I then loop through the views adding them to the row until it reaches the maximum width, in which case I start adding to a new row.
This is just the init and final stage combining and outputting the rows in the VStack
struct WrappedHStack<Content: View>: View {
private let content: [Content]
private let spacing: CGFloat = 8
private let geometry: GeometryProxy
init(geometry: GeometryProxy, content: [Content]) {
self.content = content
self.geometry = geometry
}
var body: some View {
let rowBuilder = RowBuilder(spacing: spacing,
containerWidth: geometry.size.width)
let rowViews = rowBuilder.generateRows(views: content)
let finalView = ForEach(rowViews.indices) { rowViews[$0] }
VStack(alignment: .center, spacing: 8) {
finalView
}.frame(width: geometry.size.width)
}
}
extension WrappedHStack {
init<Data, ID: Hashable>(geometry: GeometryProxy, #ViewBuilder content: () -> ForEach<Data, ID, Content>) {
let views = content()
self.geometry = geometry
self.content = views.data.map(views.content)
}
init(geometry: GeometryProxy, content: () -> [Content]) {
self.geometry = geometry
self.content = content()
}
}
The magic happens in here
extension WrappedHStack {
struct RowBuilder {
private var spacing: CGFloat
private var containerWidth: CGFloat
init(spacing: CGFloat, containerWidth: CGFloat) {
self.spacing = spacing
self.containerWidth = containerWidth
}
func generateRows<Content: View>(views: [Content]) -> [AnyView] {
var rows = [AnyView]()
var currentRowViews = [AnyView]()
var currentRowWidth: CGFloat = 0
for (view) in views {
let viewWidth = view.getSize().width
if currentRowWidth + viewWidth > containerWidth {
rows.append(createRow(for: currentRowViews))
currentRowViews = []
currentRowWidth = 0
}
currentRowViews.append(view.erasedToAnyView())
currentRowWidth += viewWidth + spacing
}
rows.append(createRow(for: currentRowViews))
return rows
}
private func createRow(for views: [AnyView]) -> AnyView {
HStack(alignment: .center, spacing: spacing) {
ForEach(views.indices) { views[$0] }
}
.erasedToAnyView()
}
}
}
and here's extensions I used
extension View {
func erasedToAnyView() -> AnyView {
AnyView(self)
}
func getSize() -> CGSize {
UIHostingController(rootView: self).view.intrinsicContentSize
}
}
You can see the full code with some examples here:
https://gist.github.com/kanesbetas/63e719cb96e644d31bf027194bf4ccdb
I have something like this code (rather long). In simple scenarios it works ok, but in deep nesting with geometry readers it doesn't propagate its size well.
It would be nice if this views wraps and flows like Text() extending parent view content, but it seems to have explicitly set its height from parent view.
https://gist.github.com/michzio/a0b23ee43a88cbc95f65277070167e29
Here is the most important part of the code (without preview and test data)
private func flow(in geometry: GeometryProxy) -> some View {
print("Card geometry: \(geometry.size.width) \(geometry.size.height)")
return ZStack(alignment: .topLeading) {
//Color.clear
ForEach(data, id: self.dataId) { element in
self.content(element)
.geometryPreference(tag: element\[keyPath: self.dataId\])
/*
.alignmentGuide(.leading) { d in
print("Element: w: \(d.width), h: \(d.height)")
if (abs(width - d.width) > geometry.size.width)
{
width = 0
height -= d.height
}
let result = width
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
width = 0 //last item
} else {
width -= d.width
}
return result
}
.alignmentGuide(.top) { d in
let result = height
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
height = 0 // last item
}
return result
}*/
.alignmentGuide(.top) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.y ?? 0
}
.alignmentGuide(.leading) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.x ?? 0
}
}
}
.background(Color.pink)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
//.animation(self.loaded ? .linear(duration: 1) : nil)
.onPreferenceChange(_GeometryPreferenceKey.self, perform: { preferences in
DispatchQueue.main.async {
let (alignmentGuides, totalHeight) = self.calculateAlignmentGuides(preferences: preferences, geometry: geometry)
self.alignmentGuides = alignmentGuides
self.totalHeight = totalHeight
self.availableWidth = geometry.size.width
}
})
}
func calculateAlignmentGuides(preferences: \[_GeometryPreference\], geometry: GeometryProxy) -> (\[AnyHashable: CGPoint\], CGFloat) {
var alignmentGuides = \[AnyHashable: CGPoint\]()
var width: CGFloat = 0
var height: CGFloat = 0
var rowHeights: Set<CGFloat> = \[\]
preferences.forEach { preference in
let elementWidth = spacing + preference.rect.width
if width + elementWidth >= geometry.size.width {
width = 0
height += (rowHeights.max() ?? 0) + spacing
//rowHeights.removeAll()
}
let offset = CGPoint(x: 0 - width, y: 0 - height)
print("Alignment guides offset: \(offset)")
alignmentGuides\[preference.tag\] = offset
width += elementWidth
rowHeights.insert(preference.rect.height)
}
return (alignmentGuides, height + (rowHeights.max() ?? 0))
}
}
I had the same problem I've, to solve it I pass the object item to a function which first creates the view for the item, then through the UIHostController I will calculate the next position based on the items width. the items view is then returned by the function.
import SwiftUI
class TestItem: Identifiable {
var id = UUID()
var str = ""
init(str: String) {
self.str = str
}
}
struct AutoWrap: View {
var tests: [TestItem] = [
TestItem(str:"Ninetendo"),
TestItem(str:"XBox"),
TestItem(str:"PlayStation"),
TestItem(str:"PlayStation 2"),
TestItem(str:"PlayStation 3"),
TestItem(str:"random"),
TestItem(str:"PlayStation 4"),
]
var body: some View {
var curItemPos: CGPoint = CGPoint(x: 0, y: 0)
var prevItemWidth: CGFloat = 0
return GeometryReader { proxy in
ZStack(alignment: .topLeading) {
ForEach(tests) { t in
generateItem(t: t, curPos: &curItemPos, containerProxy: proxy, prevItemWidth: &prevItemWidth)
}
}.padding(5)
}
}
func generateItem(t: TestItem, curPos: inout CGPoint, containerProxy: GeometryProxy, prevItemWidth: inout CGFloat, hSpacing: CGFloat = 5, vSpacing: CGFloat = 5) -> some View {
let viewItem = Text(t.str).padding([.leading, .trailing], 15).background(Color.blue).cornerRadius(25)
let itemWidth = UIHostingController(rootView: viewItem).view.intrinsicContentSize.width
let itemHeight = UIHostingController(rootView: viewItem).view.intrinsicContentSize.height
let newPosX = curPos.x + prevItemWidth + hSpacing
let newPosX2 = newPosX + itemWidth
if newPosX2 > containerProxy.size.width {
curPos.x = hSpacing
curPos.y += itemHeight + vSpacing
} else {
curPos.x = newPosX
}
prevItemWidth = itemWidth
return viewItem.offset(x: curPos.x, y: curPos.y)
}
}
struct AutoWrap_Previews: PreviewProvider {
static var previews: some View {
AutoWrap()
}
}
iOS 16 has a new Layout protocol that's perfect for that task. I've written a library with the line-wrapping behavior. It can handle different types of subviews and alignment guide values.
You need to handle line configurations right after Text View. Don't use lineLimit(1) if you need multiple lines.
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(10)
.multilineTextAlignment(.leading)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}

How do I create this effect using SwiftUI

See this gif
What is happening on this gif is: the finger touches the white area on any point on the right part and drags to the left. As the finger drags, these 3 buttons zoom in and appear.
Assuming the buttons zoom from scale = 0 to scale = 1, Does not matter if I release the finger when the scale is at any value bigger than 0. The buttons will zoom to scale 1 automatically.
NOTE: The animation is slow because I am sliding the finger slowly, but the animation follows the finger drag. If I drag left, buttons zoom in, if I drag right, buttons zoom out.
How do I do that with SwiftUI.
I have this code so far for the whole thing.
struct FileManagerPanelListItem: View {
var body: some View {
ZStack{
VStack {
ZStack {
Image("image")
.resizable()
.frame(width: 190, height: 190, alignment: .center)
HStack(alignment:.center){
FileManagerPanelButton("share", Color.white, Color.gray, {})
FileManagerPanelButton("duplicate", Color.white, Color.gray, {})
FileManagerPanelButton("delete", Color.white, Color.red, {})
}
.frame(maxWidth:.infinity)
}
Text("name")
.frame(width:170)
.background(Color.yellow)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Text("notes")
.frame(width:170)
.background(Color.blue)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth:240, maxHeight: 240)
}
}
struct FileManagerPanelListItem_Previews: PreviewProvider {
static var previews: some View {
FileManagerPanelListItem()
.previewDevice(PreviewDevice(rawValue: "iPhone 12 Pro Mini"))
}
}
struct FileManagerPanelButton: View {
typealias runOnSelectHandler = ()->Void
private var runOnSelect:runOnSelectHandler?
private var label:String
private var fgColor:Color
private var bgColor:Color
init(_ label: String,
_ fgColor:Color,
_ bgColor:Color,
_ runOnSelect: runOnSelectHandler? ) {
self.label = label
self.fgColor = fgColor
self.bgColor = bgColor
self.runOnSelect = runOnSelect
}
var body: some View {
Button(action: {
runOnSelect?()
}, label: {
Text(label)
.avenir(.ROMAN, size: 16)
.foregroundColor(fgColor)
.frame(height:60)
.frame(maxWidth:.infinity)
})
.frame(maxWidth:.infinity)
.background(bgColor)
.cornerRadius(10)
}
}
any ideas?
Something like this should work:
// view cannot be scaled to zero, so we take some small value near
private let minScale: CGFloat = 0.001
// relative distance from right side of view to open and from left side to close
private let effectiveDragSidePart: CGFloat = 0.1
struct FileManagerPanelListItem: View {
#State
var scale: CGFloat = minScale
#State
var opened = false
var body: some View {
ZStack{
VStack {
ZStack {
Image("profile")
.resizable()
.frame(width: viewWidth, height: 190, alignment: .center)
.gesture(
DragGesture()
.onChanged { value in
guard shouldProcessStartLocation(value.startLocation) else { return }
scale = translationToScale(value.translation)
}
.onEnded { value in
guard shouldProcessStartLocation(value.startLocation) else { return }
withAnimation(.spring()) {
if shouldRestore(value.predictedEndTranslation) {
scale = opened ? 1 : minScale
} else {
scale = opened ? minScale : 1
opened.toggle()
}
}
}
)
HStack(alignment:.center){
FileManagerPanelButton("share", Color.white, Color.gray, {})
.scaleEffect(scale, anchor: .center)
FileManagerPanelButton("duplicate", Color.white, Color.gray, {})
.scaleEffect(scale, anchor: .center)
FileManagerPanelButton("delete", Color.white, Color.red, {})
.scaleEffect(scale, anchor: .center)
}
.frame(maxWidth:.infinity)
}
}
}
.frame(maxWidth:240, maxHeight: 240)
}
private let viewWidth: CGFloat = 190
private func shouldRestore(_ predictedEndTranslation: CGSize) -> Bool {
if opened {
return translationToScale(predictedEndTranslation) == 1
} else {
return translationToScale(predictedEndTranslation) == minScale
}
}
private func shouldProcessStartLocation(_ startLocation: CGPoint) -> Bool {
if opened {
return startLocation.x < viewWidth * effectiveDragSidePart
} else {
return startLocation.x > viewWidth * (1 - effectiveDragSidePart)
}
}
private func translationToScale(_ translation: CGSize) -> CGFloat {
if opened {
return max(0.001, min(1, 1 - translation.width / viewWidth))
} else {
return max(0.001, -translation.width / viewWidth)
}
}
}
You can setup .spring() if the default one is not springy enough

SwiftUI - Constructing a LazyVGrid with expandable views

I'm trying to construct a two-column grid of quadratic views from an array of colors where one view expands to the size of four small views when clicked.
Javier from swiftui-lab.com gave me kind of a breakthrough with the idea of adding Color.clear as a "fake" view inside the ForEach to trick the VGrid into making space for the expanded view. This works fine for the boxes on the left of the grid. The boxes on the right, however, give me a no ends of trouble because they expand to the right and don't cause the VGrid to reallign properly:
I've tried several things like swapping the colors in the array, rotating the whole grid when one of the views on the right is clicked, adding varying numbers of Color.clear views - nothing has done the trick so far.
Here's the current code:
struct ContentView: View {
#State private var selectedColor : UIColor? = nil
let colors : [UIColor] = [.red, .yellow, .green, .orange, .blue, .magenta, .purple, .black]
private let padding : CGFloat = 10
var body: some View {
GeometryReader { proxy in
ScrollView {
LazyVGrid(columns: [
GridItem(.fixed(proxy.size.width / 2 - 5), spacing: padding, alignment: .leading),
GridItem(.fixed(proxy.size.width / 2 - 5))
], spacing: padding) {
ForEach(0..<colors.count, id: \.self) { id in
if selectedColor == colors[id] && id % 2 != 0 {
Color.clear
}
RectangleView(proxy: proxy, colors: colors, id: id, selectedColor: selectedColor, padding: padding)
.onTapGesture {
withAnimation{
if selectedColor == colors[id] {
selectedColor = nil
} else {
selectedColor = colors[id]
}
}
}
if selectedColor == colors[id] {
Color.clear
Color.clear
Color.clear
}
}
}
}
}.padding(.all, 10)
}
}
RectangleView:
struct RectangleView: View {
var proxy: GeometryProxy
var colors : [UIColor]
var id: Int
var selectedColor : UIColor?
var padding : CGFloat
var body: some View {
Color(colors[id])
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: resolveOffset(for: id))
}
// Used to offset the boxes after the expanded one to compensate for missing padding
func resolveOffset(for id: Int) -> CGFloat {
guard let selectedColor = selectedColor, let selectedIndex = colors.firstIndex(of: selectedColor) else { return 0 }
if id > selectedIndex {
return -(padding * 2)
}
return 0
}
func calculateFrame(for id: Int) -> CGFloat {
selectedColor == colors[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
I would be really grateful if you could point me in the direction of what I'm doing wrong.
P.S. If you run the code, you'll notice that the last black box is also not behaving as expected. That's another issue that I've not been able to solve thus far.
After giving up on the LazyVGrid to do the job, I kind of "hacked" two simple VStacks to be contained in a ParallelStackView. It lacks the beautiful crossover animation a LazyVGrid has and can only be implemented for two columns, but gets the job done - kind of. This is obviously a far cry from an elegant solution but I needed a workaround, so for anyone working on the same issue, here's the code (implemented as generic over the type it contains):
struct ParallelStackView<T: Equatable, Content: View>: View {
let padding : CGFloat
let elements : [T]
#Binding var currentlySelectedItem : T?
let content : (T) -> Content
#State private var selectedElement : T? = nil
#State private var selectedSecondElement : T? = nil
var body: some View {
let (transformedFirstArray, transformedSecondArray) = transformArray(array: elements)
func resolveClearViewHeightForFirstArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedSecondArray[id+1] == selectedSecondElement || (transformedSecondArray[1] == selectedSecondElement && id == 0) ? proxy.size.width + padding : 0
}
func resolveClearViewHeightForSecondArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedFirstArray[id+1] == selectedElement || (transformedFirstArray[1] == selectedElement && id == 0) ? proxy.size.width + padding : 0
}
return GeometryReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedFirstArray.count, id: \.self) { id in
if transformedFirstArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForFirstArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedFirstArray, id: id, selectedElement: selectedElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedElement == transformedFirstArray[id] {
selectedElement = nil
currentlySelectedItem = nil
} else {
selectedSecondElement = nil
selectedElement = transformedFirstArray[id]
currentlySelectedItem = selectedElement
}
}
}
}
}
}
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedSecondArray.count, id: \.self) { id in
if transformedSecondArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForSecondArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedSecondArray, id: id, selectedElement: selectedSecondElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedSecondElement == transformedSecondArray[id] {
selectedSecondElement = nil
currentlySelectedItem = nil
} else {
selectedElement = nil
selectedSecondElement = transformedSecondArray[id]
currentlySelectedItem = selectedSecondElement
}
}
}.rotation3DEffect(.init(degrees: 180), axis: (x: 0, y: 1, z: 0))
}
}
}
// You need to rotate the second VStack for it to expand in the correct direction (left).
// As now all text would be displayed as mirrored, you have to reverse that rotation "locally"
// with a .rotation3DEffect modifier (see 4 lines above).
.rotate3D()
.offset(x: resolveOffset(for: proxy))
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .topTrailing)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}.padding(10)
}
func resolveOffset(for proxy: GeometryProxy) -> CGFloat {
selectedSecondElement == nil ? proxy.size.width / 2 - padding / 2 : proxy.size.width
}
// Transform the original array to alternately contain nil and real values
// for the Color.clear views. You could just as well use other "default" values
// but I thought nil was quite explicit and makes it easier to understand what
// is going on. Then you split the transformed array into two sub-arrays for
// the VStacks:
func transformArray<T: Equatable>(array: [T]) -> ([T?], [T?]) {
var arrayTransformed : [T?] = []
array.map { element -> (T?, T?) in
return (nil, element)
}.forEach {
arrayTransformed.append($0.0)
arrayTransformed.append($0.1)
}
arrayTransformed = arrayTransformed.reversed()
var firstTransformedArray : [T?] = []
var secondTransformedArray : [T?] = []
for i in 0...arrayTransformed.count / 2 {
guard let nilValue = arrayTransformed.popLast(), let element = arrayTransformed.popLast() else { break }
if i % 2 == 0 {
firstTransformedArray += [nilValue, element]
} else {
secondTransformedArray += [nilValue, element]
}
}
return (firstTransformedArray, secondTransformedArray)
}
struct RectangleView: View {
let proxy: GeometryProxy
let elements : [T?]
let id: Int
let selectedElement : T?
let padding : CGFloat
let content : (T) -> Content
var body: some View {
content(elements[id]!)
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
}
func calculateFrame(for id: Int) -> CGFloat {
selectedElement == elements[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
}
extension View {
func rotate3D() -> some View {
modifier(StackRotation())
}
}
struct StackRotation: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
let c = CATransform3DIdentity
return ProjectionTransform(CATransform3DRotate(c, .pi, 0, 1, 0))
}
}

Dynamically size a GeometryReader height based on its elements

I'm trying to do something that's pretty straight forward in my mind.
I want a subview of a VStack to dynamically change its height based on its content (ProblematicView in the sample below).
It usually works pretty well, but in this case ProblematicView contains a GeometryReader (to simulate a HStack over several lines).
However, the GeometryReader greedily takes all the space it can (the expected behavior happens if you remove the GeometryReader and it's content). Unfortunately on the Parent view (UmbrellaView in the sample below), the UmbrellaView VStack assigns 50% of itself to the ProblematicView instead of the minimal size to display the content of the view.
I've spend a few hours playing with min/ideal/maxHeight frame arguments, to no avail.
Is what I'm trying to achieve doable?
I added pictures at the bottom to clarify visually.
struct UmbrellaView: View {
var body: some View {
VStack(spacing: 0) {
ProblematicView()
.background(Color.blue)
ScrollView(.vertical) {
Group {
Text("A little bit about this").font(.system(size: 20))
Divider()
}
Group {
Text("some").font(.system(size: 20))
Divider()
}
Group {
Text("group").font(.system(size: 20)).padding(.bottom)
Divider()
}
Group {
Text("content").font(.system(size: 20))
}
}
}
}
}
struct ProblematicView: View {
var body: some View {
let tags: [String] = ["content", "content 2 ", "content 3"]
var width = CGFloat.zero
var height = CGFloat.zero
return VStack(alignment: .center) {
Text("Some reasonnably long text that changes dynamically do can be any size").background(Color.red)
GeometryReader { g in
ZStack(alignment: .topLeading) {
ForEach(tags, id: \.self) { tag in
TagView(content: tag, color: .red, action: {})
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if tag == tags.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if tag == tags.last! {
height = 0 // last item
}
return result
})
}
}.background(Color.green)
}.background(Color.blue)
}.background(Color.gray)
}
}
struct TagView: View {
let content: String
let color: Color
let action: () -> Void?
var body: some View {
HStack {
Text(content).padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
Button(action: {}) {
Image(systemName: "xmark.circle").foregroundColor(Color.gray)
}.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7))
}
.background(color)
.cornerRadius(8.0)
}
}
struct ProblematicView_Previews: PreviewProvider {
static var previews: some View {
return ProblematicView()
}
}
struct UmbrellaView_Previews: PreviewProvider {
static var previews: some View {
return UmbrellaView()
}
}
Due to "hen-egg" problem in nature of GeometryReader the solution for topic question is possible only in run-time, because 1) initial height is unknown 2) it needs to calculate internal size based on all available external size 3) it needs to tight external size to calculated internal size.
So here is possible approach (with some additional fixes in your code)
Preview 2-3) Run-time
Code:
struct ProblematicView: View {
#State private var totalHeight = CGFloat(100) // no matter - just for static Preview !!
#State private var tags: [String] = ["content", "content 2 ", "content 3", "content 4", "content 5"]
var body: some View {
var width = CGFloat.zero
var height = CGFloat.zero
return VStack {
Text("Some reasonnably long text that changes dynamically do can be any size").background(Color.red)
VStack { // << external container
GeometryReader { g in
ZStack(alignment: .topLeading) { // internal container
ForEach(self.tags, id: \.self) { tag in
TagView(content: tag, color: .red, action: {
// self.tags.removeLast() // << just for testing
})
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if tag == self.tags.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if tag == self.tags.last! {
height = 0 // last item
}
return result
})
}
}.background(Color.green)
.background(GeometryReader {gp -> Color in
DispatchQueue.main.async {
// update on next cycle with calculated height of ZStack !!!
self.totalHeight = gp.size.height
}
return Color.clear
})
}.background(Color.blue)
}.frame(height: totalHeight)
}.background(Color.gray)
}
}
struct TagView: View {
let content: String
let color: Color
let action: (() -> Void)?
var body: some View {
HStack {
Text(content).padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
Button(action: action ?? {}) {
Image(systemName: "xmark.circle").foregroundColor(Color.gray)
}.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7))
}
.background(color)
.cornerRadius(8.0)
}
}
Based on #Asperi's code I've implemented a universal solution. It works in Previews and is compatible with iOS 13+.
My solution does not use DispatchQueue.main.async and has a convenient #ViewBuilder for you to toss in any View you like. Put the VerticalFlow in VStack or ScrollView. Set hSpacing and vSpacing to items. Add padding to the whole View.
Simple example:
struct ContentView: View {
#State var items: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"]
var body: some View {
VerticalFlow(items: $items) { item in
Text(item)
}
}
}
VerticalFlow.swift:
import SwiftUI
struct VerticalFlow<Item, ItemView: View>: View {
#Binding var items: [Item]
var hSpacing: CGFloat = 20
var vSpacing: CGFloat = 10
#ViewBuilder var itemViewBuilder: (Item) -> ItemView
#SwiftUI.State private var size: CGSize = .zero
var body: some View {
var width: CGFloat = .zero
var height: CGFloat = .zero
VStack {
GeometryReader { geometryProxy in
ZStack(alignment: .topLeading) {
ForEach(items.indices, id: \.self) { i in
itemViewBuilder(items[i])
.alignmentGuide(.leading) { dimensions in
if abs(width - dimensions.width) > geometryProxy.size.width {
width = 0
height -= dimensions.height + vSpacing
}
let leadingOffset = width
if i == items.count - 1 {
width = 0
} else {
width -= dimensions.width + hSpacing
}
return leadingOffset
}
.alignmentGuide(.top) { dimensions in
let topOffset = height
if i == items.count - 1 {
height = 0
}
return topOffset
}
}
}
.readVerticalFlowSize(to: $size)
}
}
.frame(height: size.height > 0 ? size.height : nil)
}
}
struct VerticalFlow_Previews: PreviewProvider {
#SwiftUI.State static var items: [String] = [
"One 1", "Two 2", "Three 3", "Four 4", "Eleven 5", "Six 6",
"Seven 7", "Eight 8", "Nine 9", "Ten 10", "Eleven 11",
"ASDFGHJKLqwertyyuio d fadsf",
"Poiuytrewq lkjhgfdsa mnbvcxzI 0987654321"
]
static var previews: some View {
VStack {
Text("Text at the top")
VerticalFlow(items: $items) { item in
VerticalFlowItem(systemImage: "pencil.circle", title: item, isSelected: true)
}
Text("Text at the bottom")
}
ScrollView {
VStack {
Text("Text at the top")
VerticalFlow(items: $items) { item in
VerticalFlowItem(systemImage: "pencil.circle", title: item, isSelected: true)
}
Text("Text at the bottom")
}
}
}
}
private struct VerticalFlowItem: View {
let systemImage: String
let title: String
#SwiftUI.State var isSelected: Bool
var body: some View {
HStack {
Image(systemName: systemImage).font(.title3)
Text(title).font(.title3).lineLimit(1)
}
.padding(10)
.foregroundColor(isSelected ? .white : .blue)
.background(isSelected ? Color.blue : Color.white)
.cornerRadius(40)
.overlay(RoundedRectangle(cornerRadius: 40).stroke(Color.blue, lineWidth: 1.5))
.onTapGesture {
isSelected.toggle()
}
}
}
private extension View {
func readVerticalFlowSize(to size: Binding<CGSize>) -> some View {
background(GeometryReader { proxy in
Color.clear.preference(
key: VerticalFlowSizePreferenceKey.self,
value: proxy.size
)
})
.onPreferenceChange(VerticalFlowSizePreferenceKey.self) {
size.wrappedValue = $0
}
}
}
private struct VerticalFlowSizePreferenceKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let next = nextValue()
if next != .zero {
value = next
}
}
}

SwiftUI onTapGesture on Color.clear background behaves differently to Color.blue

I am making a custom Picker in the SegmentedPickerStyle(). I want to have the same behaviour but when I tap on the area between the content and the border of one of the possible selections the onTapGesture does not work. When I add a blue background it does work but with a clear background it doesn't.
Working with blue background
Not working with clear background
Not working code:
import SwiftUI
struct PickerElementView<Content>: View where Content : View {
#Binding var selectedElement: Int
let content: () -> Content
#inlinable init(_ selectedElement: Binding<Int>, #ViewBuilder content: #escaping () -> Content) {
self._selectedElement = selectedElement
self.content = content
}
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.background(Color.clear)
// ##################################################################
.border(Color.yellow, width: 5)
}
}
}
struct PickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
var elements: [(id: Int, view: AnyView)]
#Binding var selectedElement: Int
#State var internalSelectedElement: Int = 0
private var width: CGFloat = 220
private var height: CGFloat = 100
private var cornerRadius: CGFloat = 20
private var factor: CGFloat = 0.95
private var color = Color(UIColor.systemGray)
private var selectedColor = Color(UIColor.systemGray2)
init(_ selectedElement: Binding<Int>) {
self._selectedElement = selectedElement
self.elements = [
(id: 0, view: AnyView(PickerElementView(selectedElement) {
Text("9").font(.system(.title))
})),
(id: 1, view: AnyView(PickerElementView(selectedElement) {
Text("5").font(.system(.title))
})),
]
self.internalSelectedElement = selectedElement.wrappedValue
}
func calcXPosition() -> CGFloat {
var pos = CGFloat(-self.width * self.factor / 4)
pos += CGFloat(self.internalSelectedElement) * self.width * self.factor / 2
return pos
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(self.selectedColor)
.cornerRadius(self.cornerRadius * self.factor)
.frame(width: self.width * self.factor / CGFloat(self.elements.count), height: self.height - self.width * (1 - self.factor))
.offset(x: calcXPosition())
.animation(.easeInOut(duration: 0.2))
HStack {
ForEach(self.elements, id: \.id) { item in
item.view
.gesture(TapGesture().onEnded { _ in
print(item.id)
self.selectedElement = item.id
withAnimation {
self.internalSelectedElement = item.id
}
})
}
}
}
.frame(width: self.width, height: self.height)
.background(self.color)
.cornerRadius(self.cornerRadius)
.padding()
}
}
struct PickerView_Previews: PreviewProvider {
static var previews: some View {
PickerView(.constant(1))
}
}
Change the color where I marked it.
Does anyone know why they behave differently and how I can fix this?
The one line answer is instead of setting backgroundColor, please set contentShape for hit testing.
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
// ##################################################################
// CHANGE COLOR HERE TO BLUE TO MAKE IT WORK
// ##################################################################
.contentShape(Rectangle())
// ##################################################################
.border(Color.yellow, width: 5)
}
}
Transparent views are not tappable by default in SwiftUI because their content shape is zero.
You can change this behavior by using .contentShape modifier:
Color.clear
.frame(width: 300, height: 300)
.contentShape(Rectangle())
.onTapGesture { print("tapped") }
It appears to be a design decision that any Color with an opacity of 0 is untappable.
Color.clear.onTapGesture { print("tapped") } // will not print
Color.blue.opacity(0).onTapGesture { print("tapped") } // will not print
Color.blue.onTapGesture { print("tapped") } // will print
Color.blue.opacity(0.0001).onTapGesture { print("tapped") } // will print
You can use the 4th option to get around this, as it is visually indistinguishable from the 1st.
I was struggling a similar problem to get the tap on a RoundedRectangle.
My simple solution was to set the opacity to a very low value and it worked
RoundedRectangle(cornerRadius: 12)
.fill(Color.black)
.opacity(0.0001)
.frame(width: 32, height: 32)
.onTapGesture {
...
}