Why is the scroll position of the list initialized? - swiftui

When the button is clicked, a translucent view appears.
However, at this moment, there is an issue where the scroll position of the list is initialized.
Image 1: Before button click
Image 2: After button click
Below is my SwiftUI code.
import SwiftUI
struct ContentView: View {
#State var isButtonClicked: Bool = false
var body: some View {
ZStack {
// Layer 0
TabView {
tableView()
.tabItem { Text("Tab Label 1") }
.tag(1)
Text("Tab Content 2")
.tabItem { Text("Tab Label 2") }
.tag(2)
}
// Layer 1
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
isButtonClicked.toggle()
}, label: {
Text("Button")
})
}
.padding(.trailing, 20.0)
}
.padding(.bottom, 60.0)
// Layer 2
TranslucentView(opacity: 0.6)
.opacity(isButtonClicked ? 1.0 : 0)
.onTapGesture {
isButtonClicked.toggle()
}
.animation(.linear(duration: 0.3))
}
}
}
struct TranslucentView: View {
let opacity: Double
var body: some View {
ZStack {
Color.black
.opacity(opacity)
}
.edgesIgnoringSafeArea(.all)
}
}
struct tableView: View {
let items = [
Item(name: "Apple", counts: 1),
Item(name: "Apple", counts: 3),
Item(name: "Apple", counts: 5),
Item(name: "Apple", counts: 2),
Item(name: "Apple", counts: 9),
Item(name: "Apple", counts: 4),
Item(name: "Apple", counts: 7),
Item(name: "Apple", counts: 2),
Item(name: "Apple", counts: 1),
Item(name: "Apple", counts: 3),
Item(name: "Apple", counts: 8),
Item(name: "Apple", counts: 1),
]
var body: some View {
List(items) { item in
VStack {
Text(item.name)
Text(String(item.counts))
}
}
}
}
class Item: Identifiable {
let id = UUID()
let name: String
let counts: Int
init(name: String, counts: Int) {
self.name = name
self.counts = counts
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
How can I fix this issue?
*The transparent view must cover the entire screen including the tab bar.

On state change the body is rebuilt, so tableView is recreated, so reinitialised. To avoid last we need to make view equatable, so rendering engine do not recreate it on external changes.
Here is a solution (tested with Xcode 12.1 / iOS 14.1)
TabView {
EquatableView(content: tableView()) // << here !!
.tabItem { Text("Tab Label 1") }
.tag(1)
Text("Tab Content 2")
.tabItem { Text("Tab Label 2") }
.tag(2)
}
and conform tableView to Equatable as follows
struct tableView: View, Equatable {
static func == (lhs: tableView, rhs: tableView) -> Bool {
true
}
let items = [
// ... other code

Related

Hero animation not working in List when setting row's id dynamically

Recently ran into an issue trying to perform Hero animation using matchedGeometryEffect in SwiftUI. My issue is that setting id for matchedGeometryEffect effect dynamically isn't working as expected.
This is what I have so far:
import SwiftUI
struct HeroAnimationTest: View {
let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]
#State var selectedItemInRowIndex: Int? = nil
#Namespace var namespace
var body: some View {
List {
ForEach(items, id: \.id) { item in
ItemListRow(namespace: namespace, item: item) { tappedItem in
withAnimation {
selectedItemInRowIndex = tappedItem.id
}
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(h: 16, v: 8))
}
.animation(.spring(), value: selectedItemInRowIndex)
.scrollIndicators(.never)
.listStyle(.plain)
.overlay {
if selectedItemInRowIndex != nil {
largeGreen
}
}
}
var largeGreen: some View {
ZStack {
Color.black
.onTapGesture {
withAnimation {
selectedItemInRowIndex = nil
}
}
Color.green
.frame(width: 200, height: 400)
.matchedGeometryEffect(id: selectedItemInRowIndex, in: namespace)
Text("ID -> \(selectedItemInRowIndex ?? 0)")
}
}
}
struct HeroAnimationTest_Previews: PreviewProvider {
static var previews: some View {
HeroAnimationTest()
}
}
struct Item {
let id: Int
}
struct ItemListRow: View {
#State var enlargeElement = false
let namespace: Namespace.ID
let item: Item
let onGreenTap: (Item) -> Void
var body: some View {
HStack {
Text("ID -> \(item.id)")
VStack {
Color.green
}
.frame(width: 100, height: 40)
.matchedGeometryEffect(id: item.id, in: namespace)
.onTapGesture {
onGreenTap(item)
}
VStack {
Color.yellow
}
.frame(width: 100, height: 40)
}
}
}
Current result:
I tried to hard-code id for largeGreen inside .matchedGeometryEffect(id: 3, in: namespace) to check if animation would work, and it does:
Animation with hard-coded id is the expected result, but obviously it's only working for the 3rd row. Is it even possible to achieve this effect for a green container in every row?
I'd really appreciate if anyone could take a look and give me some hint of what I'm missing here. I've been looking at this for a few hours now, but still can't figure out what went wrong.
Okay, I finally figured it out. It turns out that in order for matchedGeometryEffect to work, its id needs to be unwrapped, assigning optional just won't work.
So, what I did was:
Extracted largeGreen to a ExpandedLargeGreen with selectedItemIdx &
show parameters with #Binding property wrapper, and namespace.
And then inside .overlay closure within HeroAnimationTest unwrapped
optional Binding selectedItemInRowIndex. In case it's not nil and
show is set to true ExpandedLargeGreen will be displayed with
proper animation.
Also set animation value to show instead of selectedItemInRowIndex.
Full code:
struct HeroAnimationTest: View {
let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]
#State var selectedItemInRowIndex: Int? = nil
#State var show = false
#Namespace var namespace
var body: some View {
ZStack {
List {
ForEach(items, id: \.id) { item in
ItemListRow(namespace: namespace, item: item) { tappedItem in
withAnimation {
selectedItemInRowIndex = tappedItem.id
show.toggle()
}
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(h: 16, v: 8))
}
.animation(.spring(), value: show)
.scrollIndicators(.never)
.listStyle(.plain)
}
.overlay {
if let selectedIdx = Binding($selectedItemInRowIndex), show {
ExpandedLargeGreen(selectedItemIdx: selectedIdx, show: $show, namespace: namespace)
}
}
}
}
struct ExpandedLargeGreen: View {
#Binding var selectedItemIdx: Int
#Binding var show: Bool
var namespace: Namespace.ID
var body: some View {
ZStack {
Color.black
.onTapGesture {
withAnimation {
show.toggle()
}
}
Color.green
.frame(width: 200, height: 400)
.matchedGeometryEffect(id: selectedItemIdx, in: namespace)
Text("ID -> \(selectedItemIdx)")
}
}
}
struct HeroAnimationTest_Previews: PreviewProvider {
static var previews: some View {
HeroAnimationTest()
}
}
struct Item {
let id: Int
}
struct ItemListRow: View {
#State var enlargeElement = false
let namespace: Namespace.ID
let item: Item
let onGreenTap: (Item) -> Void
var body: some View {
HStack {
Text("ID -> \(item.id)")
VStack {
Color.green
}
.frame(width: 100, height: 40)
.matchedGeometryEffect(id: item.id, in: namespace)
.onTapGesture {
onGreenTap(item)
}
VStack {
Color.yellow
}
.frame(width: 100, height: 40)
}
}
}
Final result:

Binding two ForEach loop to update each item cell

This is my second post and I need your help as much as possible. I am creating a favorite button on my parent view and detail view. I need both buttons to work correspondent to each other. When I marked favorite on the ForEach loop of my parent view, I want to show the item is favorited in my detail view. Also, I can unfavorite or favorite from my detail view vice vasa. It is really hard for me to figure out how to bind those two ForEach loops. Below I provide an example of my codes. If you want to test with my full code, you can access it here: Making favorite button from several layers and binding two list using EnvironmentObject
struct Data: Identifiable {
let id = UUID()
let number: Int
var name1: String
let name2: String
}
public struct DataList {
static var dot = [
Data(number: 1,
name1: "Pasian Phatna",
name2: "Praise God, from whom All Blessings Flow"),
Data(number: 2,
name1: "Itna Kumpi, Ka Tuu-Cing Pa",
name2: "The King of Love My Shephaerd Is (Dominus Regit Me)"),
Data(number: 3,
name1: "Kumpipa Bia Un",
name2: "O Worship the King"),
Data(number: 4,
name1: "Pa Tung Min Than'na Om Hen",
name2: "Gloria Patri (1st Tune)"),
Data(number: 5,
name1: "Pa Tung Min Than'na Om Hen",
name2: "Gloria Patri (2nd Tune)")
]
}
struct ParentView: View {
#State var datas: [Data] = DataList.dot
var body: some View {
NavigationView {
ScrollView (.vertical, showsIndicators: false) {
LazyVStack(spacing: 5) {
ForEach (datas, id: \.id) { data in
MainData(data: data)
Divider()
.padding(.all)
}
}
}
.navigationBarHidden(true)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct MainData: View {
#State var data: Data
#State var selectedFavoriteSong: Bool = false
var body: some View {
HStack {
Button(action: {
self.selectedFavoriteSong.toggle()
}, label: {
if selectedFavoriteSong {
Image(systemName: "suit.heart.fill")
.foregroundColor(.red)
.padding(.horizontal)
} else {
Image(systemName: "suit.heart")
.padding(.horizontal)
}
})
Spacer()
Text("\(data.number)")
Spacer()
}
.padding(.top)
VStack {
Text(data.name1)
.font(.title2.smallCaps())
.fontWeight(.bold)
.foregroundColor(.primary)
Text(data.name2)
.font(.title3)
.fontWeight(.medium)
.foregroundColor(.secondary)
.italic()
}
.padding(.horizontal)
.multilineTextAlignment(.center)
}
}
Please consider, the Search() below will pop up when I tapped the search icon (which is not presented here). My point is the Search() is not directly connect to the ParentView() but the DetailView() is embedded in the Search().
struct Search: View {
#State var datas: [Data] = DataList.dot
var body: some View {
NavigationView {
ScrollView (.vertical, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach (datas, id: \.id) { data in
NavigationLink(
destination: DetailView(data: data),
label: {
Text("Search")
})
}
}.padding(.horizontal)
}
}
}
}
struct DetailView: View {
#State var data: Data
#State var selectedFavoriteSong: Bool = false
var body: some View {
HStack {
Button(action: {
self.selectedFavoriteSong.toggle()
}, label: {
if selectedFavoriteSong {
Image(systemName: "suit.heart.fill")
.foregroundColor(.red)
.padding(.horizontal)
} else {
Image(systemName: "suit.heart")
.padding(.horizontal)
}
})
Spacer()
Text("\(data.name1)")
Spacer()
}
.padding(.top)
VStack {
Text(data.name2)
.font(.title2.smallCaps())
.fontWeight(.bold)
.foregroundColor(.primary)
}
.padding(.horizontal)
.multilineTextAlignment(.center)
Spacer()
}
}
So, I want to connect the parent view and the detail view with some kind of binding property. But there is impossible to connect these two. I can store
#State var selectedFavoriteSong: Bool = false
inside the EnvironmentObject. But when I click favorite, all the items inside the ForEach loop are selected. Please help me on this issue. If you need a full code, the above link will direct to my first post. Thank you.
I'd suggest storing all of your data in an ObservableObject that is owned by the parent view and then can get passed into subviews (either explicitly or via an EnvironmentObject):
class DataSource : ObservableObject {
#Published var data : [Data] = DataList.dot
#Published var favoritedItems: Set<UUID> = []
func favoriteBinding(forID id: UUID) -> Binding<Bool> {
.init {
self.favoritedItems.contains(id)
} set: { newValue in
if newValue {
self.favoritedItems.insert(id)
} else {
self.favoritedItems.remove(id)
}
}
}
}
For example:
struct ParentView : View {
#StateObject var dataSource = DataSource()
var body: some View {
VStack {
Search(dataSource: dataSource)
}
}
}
Note that the data source stores a list of IDs that have been favorited. It uses a custom binding that can pass the boolean value down to a detail view:
struct Search: View {
#ObservedObject var dataSource : DataSource
var body: some View {
NavigationView {
ScrollView (.vertical, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach (dataSource.data, id: \.id) { data in
NavigationLink(
destination: DetailView(data: data,
selectedFavoriteSong: dataSource.favoriteBinding(forID: data.id)),
label: {
Text(data.name1)
})
}
}.padding(.horizontal)
}
}
}
}
struct DetailView: View {
var data : Data
#Binding var selectedFavoriteSong : Bool
var body: some View {
HStack {
Button(action: {
self.selectedFavoriteSong.toggle()
}, label: {
if self.selectedFavoriteSong {
Image(systemName: "suit.heart.fill")
.foregroundColor(.red)
.padding(.horizontal)
} else {
Image(systemName: "suit.heart")
.padding(.horizontal)
}
})
Spacer()
Text("\(data.name1)")
Spacer()
}
.padding(.top)
VStack {
Text(data.name2 ?? "")
.font(.title2.smallCaps())
.fontWeight(.bold)
.foregroundColor(.primary)
}
.padding(.horizontal)
.multilineTextAlignment(.center)
Spacer()
}
}

Creating a Vertical Stack of HStacks with text and sliders swiftUI

Hi I have a Stack of Hstacks that consist of a Text and a slider. The slider width extends to the edge of the text in front of it but I want them all to have the same width and appear in a straight column.
Like this below.
This is how I am forming the stacks.
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name).font(.custom("Gill Sans", size: 12)).padding(.trailing).foregroundColor(.gray)
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
this is the view where I am forming the sliders. Can someone pls help
struct MyNodeView : View {
#Binding var myNode : Sliders
var body: some View {
HStack {
Text("\(String(format: "%.f", myNode.percent))%").font(.footnote)
Slider(value: $myNode.percent, in: 0 ... 100)
}
}
}
To make sure all the text is the same width, the simplest way is .frame(width:).
struct Sliders {
var name = "Name"
var percent = CGFloat(100)
}
struct ContentView: View {
#State var defensiveLayers = [
Sliders(name: "Long name", percent: 80),
Sliders(name: "Name", percent: 70),
Sliders(name: "Ok name", percent: 65),
Sliders(name: "Hi", percent: 15),
Sliders(name: "Hello", percent: 45),
]
var body: some View {
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name).font(.custom("Gill Sans", size: 12)).padding(.trailing).foregroundColor(.gray)
.frame(width: 100) /// add frame here!
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
}
}
struct MyNodeView : View {
#Binding var myNode: Sliders
var body: some View {
HStack {
Text("\(String(format: "%.f", myNode.percent))%").font(.footnote)
Slider(value: $myNode.percent, in: 0 ... 100)
}
}
}
Result:
You could calculate the required width for the longest Text and apply that particular width to all the leading Text views in all the HStacks unifying them in the width. I've created a helper method for finding the required width for any SwiftUI View.
Helper:
extension View {
func viewSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { }
}
Usage:
#State private var textFrameWidth: CGFloat = 0
var body: some View {
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name)
.font(.custom("Gill Sans", size: 12))
.padding(.trailing)
.foregroundColor(.gray)
.viewSize { size in
textFrameWidth = textFrameWidth < size.width ? size.width : textFrameWidth
}
.frame(width: textFrameWidth)
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
}

SwiftUI: cannot delete Row in List

i have a small swiftUI programm in Xcode which let me create and delete Users in a list with a stepper to count points of the users.
everything works fine (adding users, renaming users, stepper counting) unless the deletion of the user.
it throws an error:
Fatal error: Index out of range: file
/Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-1103.2.25.8/swift/stdlib/public/core/ContiguousArrayBuffer.swift,
line 444 2020-05-23 12:06:22.854920+0200 Counter[21328:1125981] Fatal
error: Index out of range: file
/Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-1103.2.25.8/swift/stdlib/public/core/ContiguousArrayBuffer.swift,
line 444
Here is the code:
import SwiftUI
struct ContentView : View {
#State var isEditing = false
#State var stepperWerte = [3, 5, 7, 9]
#State var editText = ["Player 1", "Player 2", "Player 3", "Player 4"]
var startName = "new Player"
var startLeben = 5
var body: some View {
NavigationView {
List() {
ForEach(0..<editText.count, id: \.self) {
spieler in HStack {
if self.editText.indices.contains(spieler) {
Stepper(value: self.$stepperWerte[spieler], in: -1...10, step: 1, label: {
TextField("", text: self.$editText[spieler], onEditingChanged: {_ in }, onCommit: {self.saveText(id: spieler, Text: self.editText[spieler])} )
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
Text("\(self.stepperWerte[spieler]) - \(spieler) - \(self.editText.count)")})
}
}
}
.onDelete(perform: spielerLoeschen)
.frame(width: nil, height: nil, alignment: .trailing)
}
.navigationBarTitle(Text("Nav_Title"))
.navigationBarItems(leading: Button(action: { self.isEditing.toggle() }) { Text(isEditing ? "Done" : "Edit").frame(width: 85, height: 40, alignment: .leading) },
trailing: Button(action: spielerHinzufuegen, label: { Image(systemName: "plus") }) )
.environment(\.editMode, .constant(self.isEditing ? EditMode.active : EditMode.inactive)).animation(Animation.spring())
}
}
func spielerLoeschen(at offsets: IndexSet) {
stepperWerte.remove(atOffsets: offsets)
editText.remove(atOffsets: offsets)
}
func spielerHinzufuegen() {
stepperWerte.append(startLeben)
editText.append(startName)
}
func saveText(id: Int, Text: String) {
editText[id] = Text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
(ignore the "if" after the HStack, it has no real effect and those extra prints in the last Text to show the index and the total count)
if i dump the arrays (stepperWerte and editText) they are removed the right way -> the player selected for deletion will be removed correctly from the two arrays.
if i change
TextField("", text: self.$editText[spieler]
to
TextField("", text: self.$editText[0]
it works (unless naturally it displays the first player in all rows and i got the same error after deleting all the players (=rows))
any help would be great - thank you!
According to #Asperi i have changed my code to the following:
import SwiftUI
struct BetterTextField : View {
var container: Binding<[String]>
var index: Int
#State var text: String
var body: some View {
TextField("", text: self.$text, onCommit: {
self.container.wrappedValue[self.index] = self.text
})
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
}
}
struct ContentView : View {
#State var isEditing = false
#State var stepperWerte = [3, 5, 7, 9]
#State var editText = ["Player 1", "Player 2", "Player 3", "Player 4"]
var startName = "new Player"
var startLeben = 5
var body: some View {
NavigationView {
List() {
ForEach(0..<editText.count, id: \.self) {
spieler in HStack {
if self.editText.indices.contains(spieler) {
Stepper(value: self.$stepperWerte[spieler], in: -1...10, step: 1, label: {
BetterTextField(container: self.$editText, index: self.editText.firstIndex(of: self.editText[spieler])!, text: self.editText[spieler])
Text("\(self.stepperWerte[spieler]) - \(spieler) - \(self.editText.count)")})
}
}
}
.onDelete(perform: spielerLoeschen)
.frame(width: nil, height: nil, alignment: .trailing)
}
.navigationBarTitle(Text("Nav_Title"))
.navigationBarItems(leading: Button(action: { self.isEditing.toggle() }) { Text(isEditing ? "Done" : "Edit").frame(width: 85, height: 40, alignment: .leading) },
trailing: Button(action: spielerHinzufuegen, label: { Image(systemName: "plus") }) )
.environment(\.editMode, .constant(self.isEditing ? EditMode.active : EditMode.inactive)).animation(Animation.spring())
}
}
func spielerLoeschen(at offsets: IndexSet) {
stepperWerte.remove(atOffsets: offsets)
editText.remove(atOffsets: offsets)
}
func spielerHinzufuegen() {
stepperWerte.append(startLeben)
editText.append(startName)
}
func saveText(id: Int, Text: String) {
editText[id] = Text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
... and it works - thank you!
but:
is this a bug in SwiftUI or intentional?
The problem is that you are not using your items directly in the ForEach loop. Consider using structs for your data as objects and make them identifiable.
struct Player : Identifiable {
let id = UUID()
var stepperWerte: Int
var editText : String
}
struct ContentView : View {
#State var isEditing = false
#State var players = [Player(stepperWerte: 3, editText: "Player 1"), Player(stepperWerte: 5, editText: "Player 2"), Player(stepperWerte: 7, editText: "Player 3"), Player(stepperWerte: 9, editText: "Player 4")]
var startName = "new Player"
var startLeben = 5
var body: some View {
NavigationView {
List() {
ForEach(self.players) { player in
SecondView(player: player)
}
.onDelete(perform: spielerLoeschen)
}
.frame(width: nil, height: nil, alignment: .trailing)
.navigationBarTitle(Text("Nav_Title"))
.navigationBarItems(leading: Button(action: { self.isEditing.toggle() }) { Text(isEditing ? "Done" : "Edit").frame(width: 85, height: 40, alignment: .leading) },
trailing: Button(action: spielerHinzufuegen, label: { Image(systemName: "plus") }) )
.environment(\.editMode, .constant(self.isEditing ? EditMode.active : EditMode.inactive)).animation(Animation.spring())
}
}
func spielerLoeschen(at offsets: IndexSet) {
players.remove(atOffsets: offsets)
}
func spielerHinzufuegen() {
players.insert(Player(stepperWerte: 4, editText: "Neuer Player"), at: 0)
}
}
struct SecondView : View {
var player : Player
#State var stepperWerte : Int
#State var name : String
init(player : Player)
{
self._stepperWerte = State(initialValue: player.stepperWerte)
self._name = State(initialValue: player.editText)
self.player = player
}
var body: some View
{
Stepper(value: self.$stepperWerte, in: -1...10, step: 1, label: {
TextField("", text: self.$name)
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
Text("\(player.stepperWerte)")
})
}
}
I created a struct Player, and then an array of many Players. In the ForEach you can directly use players as Player confirms to Identifiable protocol. This is way easier as you can access a player object in your ForEach loop and you do not have to access everything with indices. In the deleting function you just delete the object out of the array or add something new to it. Deleting now works fine.
I have removed some code from the list row, just to reproduce it easier, just if you are wondering.

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
}
}
}