How do I make a SwiftUI Scroll view shrink-to-content? - swiftui

I have a SwiftUI GUI with two lists stacked on top of each other. Each list has a variable number of items. I'd like both lists to shrink to fit their contents.
If I use VStack around ForEach, the simple case works (see example below). There is a spacer near the bottom, so the lists shift to the top of the window like I expect.
Now, sometimes the lists are large and I want a Scroll view and a maximum height, but I still want the list to shrink when it has fewer items. But as soon as I add the Scroll view, the Scroll view starts to take up all the space it can. If I assign it a maximum, then it doesn't shrink to fit it's contents anymore.
In the example below (as written) I get the resizing behavior I want (without the max size). If I uncomment the Scroll view, then it consumes all the space, if I uncomment the frame modifier, it works, but the size is fixed.
struct ContentView: View {
#State var List1: [String] = [ ]
#State var List2: [String] = [ ]
var body: some View {
VStack {
Button("1-5") {
List1=[ "1" ]
List2=[ "a", "b", "c", "d", "e" ]
}
Button("3-3") {
List1=[ "1", "2", "3" ]
List2=[ "a", "b", "c" ]
}
Button("5-1") {
List1=[ "1", "2", "3", "4", "5" ]
List2=[ "a" ]
}
//ScrollView {
VStack {
ForEach(List1.indices, id: \.self) { idx in
Text(List1[idx])
}
}
//}
//.frame(maxHeight: 40)
Text("middle")
VStack {
ForEach(List2.indices, id: \.self) { idx in
Text(List2[idx])
}
}
Spacer()
Text("last")
}
}
}

You need PreferenceKey to calculate the size of your ScrollView content. Here a getSize function that can help you :
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct SizeModifier: ViewModifier {
private var sizeView: some View {
GeometryReader { geometry in
Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
}
}
func body(content: Content) -> some View {
content.overlay(sizeView)
}
}
extension View {
func getSize(perform: #escaping (CGSize) -> ()) -> some View {
self
.modifier(SizeModifier())
.onPreferenceChange(SizePreferenceKey.self) {
perform($0)
}
}
}
You have to compare the height of your content (with getSize) and the height of the ScrollView (with GeometryReader), and set the frame accordingly :
struct SwiftUIView12: View {
#State private var items: [String] = ["One", "Two", "Three"]
#State private var scrollViewSize: CGSize = .zero
var body: some View {
GeometryReader { proxy in
ScrollView {
ForEach(items, id: \.self) { item in
Text(item)
.padding()
}
.frame(maxWidth: .infinity)
.getSize {scrollViewSize = $0}
}
.frame(height: scrollViewSize.height < proxy.size.height ? scrollViewSize.height : .none )
.background(Color.blue.opacity(0.2))
}
.navigationTitle("Test")
.toolbar {
Button("Many items") {
items = (1 ... 30).map { _ in String.random(length: 10) }
}
}
}
}

Related

how to make the recursive view the same width?

I want to make a recursive view like this:
But what I have done is like this:
It's a tvOS application, the sample code is:
struct MainView: View {
#State private var selectedItem: ListItem?
var body: some View {
VStack {
RecursiveFolderListView(fileId: "root", selectedItem: $selectedItem)
}
}
}
struct RecursiveFolderListView: View {
#EnvironmentObject var api: API
var fileId: String
#Binding var selectedItem: ListItem?
#State private var currentPageSelectedItem: ListItem?
#State private var list: [ListItem]?
#State private var theId = 0
var body: some View {
HStack {
if let list = list, list.count > 0 {
ScrollView(.vertical) {
ForEach(list, id: \.self) { item in
Button {
selectedItem = item
currentPageSelectedItem = item
} label: {
HStack {
Text(item.name)
.font(.callout)
.multilineTextAlignment(.center)
.lineLimit(1)
Spacer()
if item.fileId == selectedItem?.fileId {
Image(systemName: "checkmark.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.foregroundColor(.green)
}
}
.frame(height: 60)
}
}
}
.focusSection()
.onChange(of: currentPageSelectedItem) { newValue in
if list.contains(where: { $0 == newValue }) {
theId += 1
}
}
} else {
HStack {
Spacer()
Text("Empty")
Spacer()
}
}
if let item = currentPageSelectedItem, item.fileId != fileId {
RecursiveFolderListView(fileId: item.fileId, selectedItem: $selectedItem)
.id(theId)
}
}
.task {
list = try? await api.getFiles(parentId: fileId)
}
}
}
It's a list view, and when the user clicks one item in the list, it will expand the next folder list to the right. The expanded lists and the left one will have the same width.
I think it needs Geometryreader to get the full width, and pass down to the recursive hierarchy, but how to get how many views in the recursive logic?
I know why my code have this behavior, but I don't know how to adjust my code, to make the recursive views the same width.
Since you didn't include definitions of ListItem or API in your post, here are some simple definitions:
struct ListItem: Hashable {
let fileId: String
var name: String
}
class API: ObservableObject {
func getFiles(parentId: String) async throws -> [ListItem]? {
return try FileManager.default
.contentsOfDirectory(atPath: parentId)
.sorted()
.map { name in
ListItem(
fileId: (parentId as NSString).appendingPathComponent(name),
name: name
)
}
}
}
With those definitions (and changing the root fileId from "root" to "/"), we have a simple filesystem browser.
Now on to your question. Since you want each column to be the same width, you should put all the columns into a single HStack. Since you use recursion to visit the columns, you might think that's not possible, but I will demonstrate that it is possible. In fact, it requires just three simple changes:
Change VStack in MainView to HStack.
Change the outer HStack in RecursiveFolderListView to Group.
Move the .task modifier to the inner HStack around the "Empty" text, in the else branch.
The resulting code (with unchanged chunks omitted):
struct MainView: View {
#State private var selectedItem: ListItem? = nil
var body: some View {
HStack { // ⬅️ changed
RecursiveFolderListView(fileId: "/", selectedItem: $selectedItem)
}
}
}
struct RecursiveFolderListView: View {
...
var body: some View {
Group { // ⬅️ changed
if let list = list, list.count > 0 {
...
} else {
HStack {
Spacer()
Text("Empty")
Spacer()
}
.task { // ⬅️ moved to here
list = try? await api.getFiles(parentId: fileId)
}
}
}
// ⬅️ .task moved from here
}
}
I don't have the tvOS SDK installed, so I tested by commenting out the use of .focusSection() and running in an iPhone simulator:
This works because the subviews of a Group are “flattened” into the Group's parent container. So when SwiftUI sees a hierarchy like this:
HStack
Group
ScrollView (first column)
Group
ScrollView (second column)
Group
ScrollView (third column)
HStack (fourth column, "Empty")
SwiftUI flattens it into this:
HStack
ScrollView (first column)
ScrollView (second column)
ScrollView (third column)
HStack (fourth column, "Empty")
I moved the .task modifier because otherwise it would be attached to the Group, which would pass it on to all of its child views, but we only need the task applied to one child view.
Although rob's answer is perfect, I want to share another approach.
class SaveToPageViewModel: ObservableObject {
#Published var fileIds = [String]()
func tryInsert(fileId: String, parentFileId: String?) {
if parentFileId == nil {
fileIds.append(fileId)
} else if fileIds.last == parentFileId {
fileIds.append(fileId)
} else if fileIds.last == fileId {
// do noting, because this was caused by navigation bug, onAppear called twice
} else {
var copy = fileIds
copy.removeLast()
while copy.last != parentFileId {
copy.removeLast()
}
copy.append(fileId)
fileIds = copy
}
}
}
And wrap the container a GeometryReader and using the SaveToPageViewModel to follow the recursive view's length:
#State var itemWidth: CGFloat = 0
...
GeometryReader { proxy in
...
RecursiveFolderListView(fileId: "root", selectedItem: $selectedItem, parentFileId: nil, itemWidth: itemWidth)
.environmentObject(viewModel)
...
}
.onReceive(viewModel.$fileIds) { fileIds in
itemWidth = proxy.size.width / CGFloat(fileIds.count)
}
And in the RecursiveFolderListView, change the model data:
RecursiveFolderListView(fileId: item.fileId, selectedItem: $selectedItem, parentFileId: fileId, itemWidth: itemWidth)
.id(theId)
...
}
.onAppear {
model.tryInsert(fileId: fileId, parentFileId: parentFileId)
}

SwiftUI .searchable modifier makes searchbar twink

I'm using .searchable to embed a search bar into a list view. However, when the .searchable is nested in a NavigationStack (iOS 16 API), it is twinking when the page is loaded (shows up at first and disappears quickly). I hope both pages have a searchable feature.
I can reproduce this issue both on my device iPhone 12 and the simulator iPhone 14. Am I putting the modifier in an incorrect place?
struct ContentView: View {
#State private var selection = "2"
#State var items: [String] = ["0", "1", "2", "3", "4"]
#State var searchText = ""
var body: some View {
NavigationStack {
List {
ForEach(items, id: \.self) { item in
NavigationLink {
NestedListView(items: items)
} label: {
Text(item)
}
}
}
.searchable(text: $searchText)
}
}
}
struct NestedListView: View {
var items: [String]
#State var searchText = ""
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
}
.searchable(text: $searchText)
}
}

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: control zIndex on views with matchedGeometryEffect

I am building a custom SegmentedPicker in SwiftUI where the selector adjusts its size to fit the frame of each picker item. I did it already using PreferenceKeys as inspired by this post (Inspecting the View Tree) for uniformly sized items like shown below:
I think I can simplify my implementation considerably and avoid using PreferencyKeys altogether by using a .matchedGeometryEffect(). My idea was to present a selector behind each item only when that item has been selected and sync the transition using the .matchedGeometryEffect(). Almost everything is working except for an issue where the selector will be in front of the previously selected item. I tried explicitly setting the zIndex, but it does not seem to affect the result:
The code:
struct MatchedGeometryPicker: View {
#Namespace private var animation
#Binding var selection: Int
let items: [String]
var body: some View {
HStack {
ForEach(items.indices) { index in
ZStack {
if isSelected(index) {
Color.gray.clipShape(Capsule())
.matchedGeometryEffect(id: "selector", in: animation)
.animation(.easeInOut)
.zIndex(0)
}
itemView(for: index)
.padding(7)
.zIndex(1)
}
.fixedSize()
}
}
.padding(7)
}
func itemView(for index: Int) -> some View {
Text(items[index])
.frame(minWidth: 0, maxWidth: .infinity)
.foregroundColor(isSelected(index) ? .black : .gray)
.font(.caption)
.onTapGesture { selection = index }
}
func isSelected(_ index: Int) -> Bool { selection == index }
}
And in ContentView:
struct ContentView: View {
#State private var selection = 0
let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long item 5"]
var body: some View {
MatchedGeometryPicker(selection: $selection, items: pickerItems)
.background(Color.gray.opacity(0.10).clipShape(Capsule()))
.padding(.horizontal, 5)
}
}
Any ideas how to fix this?
I managed to solve all the animation issues I had with the picker implementation that uses PreferenceKeys when the items have different frame sizes. This does not solve the issue I have with the zIndex and the .matchedGeometryEffect(), so I will not accept my own answer, but I'll post it as a reference in case anyone needs it in the future.
The code:
public struct PKPicker: View {
#Binding var selection: Int
#State private var frames: [CGRect] = []
let items: [String]
public init(
selection: Binding<Int>,
items: [String])
{
self._selection = selection
self._frames = State(wrappedValue: Array<CGRect>(repeating: CGRect(),
count: items.count))
self.items = items
}
public var body: some View {
ZStack(alignment: .topLeading) {
selector
HStack {
ForEach(items.indices) { index in
itemView(for: index)
}
}
}
.onPreferenceChange(PKPickerItemPreferenceKey.self) { preferences in
preferences.forEach { frames[$0.id] = $0.frame }
}
.coordinateSpace(name: "picker2")
}
var selector: some View {
Color.gray.opacity(0.25).clipShape(Capsule())
.frame(width: frames[selection].size.width,
height: frames[selection].size.height)
.offset(x: frames[selection].minX, y: frames[selection].minY)
}
func itemView(for index: Int) -> some View {
Text(items[index])
.fixedSize()
.padding(7)
.foregroundColor(isSelected(index) ? .black : .gray)
.font( .caption)
.onTapGesture { selection = index }
.background(PKPickerItemPreferenceSetter(id: index))
}
func isSelected(_ index: Int) -> Bool {
index == selection
}
}
struct PKPickerItemPreferenceData: Equatable {
let id: Int
let frame: CGRect
}
struct PKPickerItemPreferenceKey: PreferenceKey {
typealias Value = [PKPickerItemPreferenceData]
static var defaultValue: [PKPickerItemPreferenceData] = []
static func reduce(
value: inout [PKPickerItemPreferenceData],
nextValue: () -> [PKPickerItemPreferenceData])
{
value.append(contentsOf: nextValue())
}
}
struct PKPickerItemPreferenceSetter: View {
let id: Int
let coordinateSpace = CoordinateSpace.named("picker2")
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: PKPickerItemPreferenceKey.self,
value: [PKPickerItemPreferenceData(
id: id, frame: geometry.frame(in: coordinateSpace))])
}
}
}
And in ContentView
struct ContentView: View {
#State private var selection = 0
let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long Item 5"]
var body: some View {
PKPicker(selection: $selection.animation(.easeInOut), items: pickerItems)
.padding(7)
.background(Color.gray.opacity(0.10).clipShape(Capsule()))
.padding(5)
}
Result:

How can i remove the trailing red animation at the end of swipe deleting using .onDelete swiftUI

This is the code :
struct ContentView: View {
#State var names = ["A" , "B", "C", "D"]
var body: some View {
List {
ForEach(names, id: \.self ) { name in
Group {
testStruct(name: name)
}
}.onDelete(perform: removeItems)
}
}
private func removeItems (indexSet: IndexSet) {
names.remove(atOffsets: indexSet)
}
}
struct testStruct: View , Identifiable {
#State var name: String
let id = UUID()
var body: some View {
HStack {
Text(name)
Spacer()
Image(systemName: "folder.fill")
}
}
}
I am unable to remove the trailing red animation on swiping onDelete . Is there any elegant way of doing that . .animation() seem not to be working