SwiftUI: Prevent view being inside VStack to expand - list

I have such VStack with list inside it
VStack(alignment: .leading, spacing: 16) {
Text("Contacts")
.font(.custom("AvenirNext-DemiBold", size: 20))
.foregroundColor(Color("DarkTitle"))
.padding(8).layoutPriority(1)
List(self.contacts) { contact in
ContactOption(contact: contact)
.padding(.horizontal, 4)
} //.frame(height: 240)
}
The problem with this code is that List tries to expand content as much as it can here taking up entire screen in spite of having just 4 contacts.
I can set this height to fixed value using frame(height: 240)
I consider wether there is possibility to enforce List to wrap its content like Text() view does.
i.e. if there is 4 rows in List wrap content to display just this 4 rows, if there is 8 rows expand to this 8 rows. Then I could set some max height ex. 400 above which List could not expand anymore and then it will be scrollable.

ok, i tried a bit and i am not sure whether you can use it or not, but check this out: (just tap on add and remofe to see how the list gets bigger and smaller)
struct ContactOption : View {
var contact: String
var body: some View {
Text(contact)
}
}
struct ListView : View {
var contacts: [String]
var body : some View {
// List(self.contacts, id: \.self) { contact in
// ContactOption(contact: contact)
// .padding(.horizontal, 4)
// }
List {
ForEach (contacts, id: \.self) { contact in
Text (contact)
}
}
}
}
struct ContentView: View {
#State var contacts = ["Chris", "Joe", "Carla", "another"]
var body: some View {
VStack() {
HStack {
Button("Add") {
self.contacts.append("dust")
}
Button("Remove") {
self.contacts = self.contacts.dropLast()
}
}
Text("Contacts")
.foregroundColor(Color.blue)
.padding(8).layoutPriority(1)
Form {
ListView(contacts: contacts)
Section(footer: Text("hi")) {
Text("hi")
}
}
Divider()
Text("end list")
.foregroundColor(Color.orange)
}
}
}

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

Picker scroll through one element horizontally

I have a SwiftUI Picker in which an item is selected. The text of one element can be large, so I used UIKit UIPickerView and set the manual height to 100, but at some point it became not enough. Is it possible to make scrolling horizontal for each element?
I want to get something like this:
Picker("Items", select: self._selectItem) {
ForEach(self.items, id: \.self) { item in
ScrollView(.horizontal, showsIndicators: false) {
Text(item.description)
}
.tag(item)
}
}
That should work fine. If you only want to scroll one item, you would have to insert a check of the item length.
let items = [
"A long item text.",
"And a even longer item text which is really going further.",
"Another item text which is really going further."
]
struct ContentView: View {
#State private var select = ""
var body: some View {
VStack {
Text("Make your selection!")
List(items, id: \.self) { item in
ScrollView(.horizontal) {
Text(item)
}
.listRowBackground(item == select ? Color.red : Color.white)
.onTapGesture {
select = item
}
}
}
}
}
I would strongly suggest to separate the picking from the text display and scrolling, e.g. like this:
struct ContentView: View {
#State private var select = items[0]
var body: some View {
VStack {
Text("Make your selection!")
Picker("Items", selection: $select) {
ForEach(items) { item in
Text(item.title)
.tag(item)
}
}
ScrollView {
Text(select.text)
}
.padding()
.frame(height: 200)
}
}
}

How to recreate the grid (in screenshot) in SwiftUI without breaking navigation?

I am trying to recreate a layout similar to the Reminders app. Looking at it makes me think it was built with SwiftUI. I also believe Apple mentioned so in one of the WWDC videos (can't remember which one).
This above screenshot seems to be a List, with a LazyVGrid as the first View inside the List. Tapping on each of the items in the LazyVGrid, such as Today, Scheduled, All and Flagged, navigates to the relevant screen, which means they are all NavigationLinks. Also note that the LazyVGrid has 2 columns.
And then there is another section "My Lists" which has rows which look like regular list rows in a List with style .insetGrouped. Also, every item in this Section is a NavigationItem, and thus comes with the disclosure indicator on the right as usual. Recreating this is trivial, so it has been left out from the MRE.
I am having trouble recreating the first section, which has that LazyVGrid. I faced 3 problems (as mentioned in the image), of which I have been able to solve the first one only. The other two problems remain. I want to know if this MRE can be fixed, or is my entire approach incorrect.
I am including a minimum reproducible example below.
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
RemindersView()
}
}
}
struct RemindersView: View {
private var columns: [GridItem] = [GridItem(.adaptive(minimum: 150))]
private var smartLists: [SmartList] = SmartList.sampleLists
var body: some View {
NavigationView {
List {
Section(header: Text("Using LazyVGrid")) {
grid
}
Section(header: Text("Using HStack")) {
hstack
}
}
.navigationTitle("Store")
}
.preferredColorScheme(.dark)
}
private var grid: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(smartLists) { smartList in
// This use of **ZStack with an EmptyView with opacity 0** is a hack being used to avoid the disclosure indicator on each item in the grid
ZStack(alignment: .leading) {
NavigationLink( destination: SmartListView(list: smartList)) {
EmptyView()
}
.opacity(0)
SmartListView(list: smartList)
}
}
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
private var hstack: some View {
ScrollView(.horizontal) {
HStack {
ForEach(smartLists) { smartList in
NavigationLink(destination: SmartListView(list: smartList)) {
SmartListView(list: smartList)
}
.buttonStyle(.plain)
}
}
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
struct RemindersView_Previews: PreviewProvider {
static var previews: some View {
RemindersView()
}
}
struct SmartList: Identifiable {
var id: UUID = UUID()
var title: String
var count: Int
var icon: String
var iconColor: Color
static var sampleLists: [SmartList] {
let today = SmartList(title: "Today", count: 5, icon: "20.circle.fill", iconColor: .blue)
let scheduled = SmartList(title: "Scheduled", count: 12, icon: "calendar.circle.fill", iconColor: .red)
let all = SmartList(title: "All", count: 77, icon: "tray.circle.fill", iconColor: .gray)
let flagged = SmartList(title: "Flagged", count: 5, icon: "flag.circle.fill", iconColor: .orange)
return [today, scheduled, all, flagged]
}
}
struct SmartListView: View {
var list: SmartList
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center) {
Image(systemName: list.icon)
.renderingMode(.original)
.font(.title)
.foregroundColor(list.iconColor)
Spacer()
Text("\(list.count)")
.font(.system(.title, design: .rounded))
.fontWeight(.bold)
.padding(.horizontal, 8)
}
Text(list.title)
.font(.system(.headline, design: .rounded))
.foregroundColor(.secondary)
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.foregroundColor(.gray.opacity(0.25))
)
.padding(2)
.frame(minWidth: 150)
}
}
EDIT 1: Adding video demo of what editing the dynamic Grid looks like and how the Grid has dynamic grid items (via the Edit button at the top right): https://imgur.com/a/TV0kifY

Same ForEach loop twice in one SwiftUI View

When I use a ForEach loop over an array twice within a view, I get the following warning at runtime:
LazyVGridLayout: the ID 84308994-9D16-48D2-975E-DC40C5F9EFFF is used by multiple child views, this will give undefined results!
The reason for this is clear so far, but what is the smartest way to work around this problem?
The following sample code illustrates the problem:
import SwiftUI
// MARK: - Model
class Data: ObservableObject
{
#Published var items: [Item] = [Item(), Item(), Item()]
}
struct Item: Identifiable
{
let id = UUID()
var name: String = ""
var description: String = ""
}
// MARK: - View
struct MainView: View {
#StateObject var data: Data
private var gridItems: [GridItem] { Array(repeating: GridItem(), count: data.items.count) }
var body: some View {
LazyVGrid(columns: gridItems, alignment: .leading, spacing: 2) {
ForEach(data.items) { item in
Text(item.name)
}
ForEach(data.items) { item in
Text(item.description)
}
}
}
}
// MARK: - App
#main
struct SwiftUI_TestApp: App {
var body: some Scene {
WindowGroup {
MainView(data: Data())
}
}
}
I could possibly divide the view into several SubViews.
Are there any other options?
Edit:
This is the body of the real app:
var body: some View {
VStack {
LazyVGrid(columns: columns, alignment: .leading, spacing: 2) {
Text("")
ForEach($runde.players) { $player in
PlayerHeader(player: $player)
}
ForEach(Score.Index.allCases) { index in
Text(index.localizedName)
ForEach(runde.players) { player in
Cell(player: player, active: player == runde.activePlayer, index: index)
}
}
Text ("")
ForEach(runde.players) { player in
PlaceView(player: player)
}
}
.padding()
}
}
If you really need that kind of grid filling, then it is possible just to use different identifiers for those ForEach containers, like
LazyVGrid(columns: gridItems, alignment: .leading, spacing: 2) {
ForEach(data.items) { item in
Text(item.name).id("\(item.id)-1") // << here !!
}
ForEach(data.items) { item in
Text(item.description).id("\(item.id)-2") // << here !!
}
}
Tested with Xcode 13beta / iOS 15
While adding identifiers within the ForEach loop sometimes works, I found that accessing the indexes from the loop with indices worked in other cases:
ForEach(items.indices, id: \.self) { i in
Text(items[i])
}

SwitUI - VStack - Jumping when text entered

I am developing a basic passcode entry screen, consisting off a top Stack to display currently entry, then some HStack's displaying the numbers
VStack(){
HStack(spacing: 20){
ForEach(codes,id: \.self){i in
Text("*")
}
}
HStack(){
<Number 1 - 3>
}
HStack(){
<Number 4 - 6>
}
HStack(){
<Number 7 - 9>
}
HStack(){
<Number 0>
}
}
This issue im facing is when there is no passcode entered the top HStack dosnt use up any space, so has a vertical height of 0, when I enter a code, it forces the whole view to jump a little as the view resizes.
How can I stop that so
If I'm being honest, it was quiet fun to build ! 😄 Don't forget to mark this answer as the right one if it solved your issue. ✅
PROBLEM
The jumping effect is due to SwiftUI updating all views positions based on available space calculated based on your content (passcode digits). The font, font weight, text size, etc… all has an effect on the available space left for other views.
SOLUTION
To avoid that, you need to a predefined frame that will let the parent view know that your digits will never take more space. Doing so, each update won't effect the position of any other view because the allocated top space would always be size you specified and not the digits sizes (or absence).
CODE
import SwiftUI
import Combine
// Using Combine to manage digits and future network calls…
class PasscodeManager: ObservableObject {
let codesQuantity = 4
#Published var codes = [Int]()
}
struct PasscodeView: View {
#StateObject private var manager = PasscodeManager()
var body: some View {
VStack {
Spacer()
// Dots placeholders and passcode digits
selectedCodes
Spacer()
// Numberpad
PasscodeLine(numbers: 1...3) { add(number: $0) }
PasscodeLine(numbers: 4...6) { add(number: $0) }
PasscodeLine(numbers: 7...9) { add(number: $0) }
PasscodeLine(numbers: 0...0) { add(number: $0) }
Spacer()
}
.padding()
}
var selectedCodes: some View {
let minDots = manager.codes.count == manager.codesQuantity ? 0:1
let maxDots = manager.codesQuantity - manager.codes.count
return HStack(spacing: 32) {
ForEach(manager.codes, id: \.self) { Text("\($0)") }
if maxDots != 0 {
ForEach(minDots...maxDots, id: \.self) { _ in
Circle().frame(width: 12)
}
}
}
.font(.title.bold())
// Setting a default height should fix your problem. 🙂
.frame(height: 70)
}
func add(number: Int) {
guard manager.codes.count < manager.codesQuantity else { return }
manager.codes.append(number)
}
}
struct PasscodeLine: View {
let numbers: ClosedRange<Int>
var select: (Int) -> Void
var body: some View {
HStack {
ForEach(numbers, id: \.self) { number in
Spacer()
Button(action: { select(number) },
label: {
Text("\(number)")
.font(.title)
.fontWeight(.medium)
.foregroundColor(Color(.label))
.padding(32)
.background(Color(.quaternarySystemFill))
.clipShape(Circle())
})
}
Spacer()
}
}
}
RESULT