SwiftUI set max height on ScrollView - swift

I have built a Chips Container based on this link. Basically is just a container that orders chips in rows. This is my code:
struct ChipsContent: View {
#ObservedObject var viewModel = ChipsViewModel()
#State private var totalHeight
= CGFloat.zero
var body: some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ScrollView {
GeometryReader { geo in
ZStack(alignment: .topLeading, content: {
ForEach(viewModel.dataObject) { chipsData in
Chips(systemImage: chipsData.systemImage,
titleKey: chipsData.titleKey,
isSelected: chipsData.isSelected)
.padding(.all, 5)
.alignmentGuide(.leading) { dimension in
if (abs(width - dimension.width) > geo.size.width) {
width = 0
height -= dimension.height
}
let result = width
if chipsData.id == viewModel.dataObject.last!.id {
width = 0
} else {
width -= dimension.width
}
return result
}
.alignmentGuide(.top) { dimension in
let result = height
if chipsData.id == viewModel.dataObject.last!.id {
height = 0
}
return result
}
}
}).background(viewHeightReader($totalHeight))
}.padding(.all, 5)
}.frame(height: height)
}
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
return GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
binding.wrappedValue = rect.size.height
}
return .clear
}
}
}
With this code, the container gets the height according to its content. I want to set a max-height to the Scroll view and if the content is greater than this max height the content should be scrollable. Is it possible to do that?

Use .frame(minHeight: …, maxHeight: …)
By setting minimum and maximum heights you are limiting the freedom of SwiftUI to decide about the size, so you have more control versus just mentioning .frame(height: …), which is treated as a recommendation but not a value that must be adhered to.

Related

Multiple animations interrupt/interfere with eachother

I attempted to apply multiple animations to an object, one that loops on a short timer and one that changes on a button press. After reducing it down to an example, it appears that multiple animations interfere with eachother (replacing all animations duration/repeatability with the most recently triggered.
Example code:
import SwiftUI
struct RectangleView: Shape {
var height: CGFloat
var width: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(height, width)
}
set {
height = newValue.first
width = newValue.second
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
var rect = CGRect()
rect.size.width = self.width
rect.size.height = self.height
path.addRect(rect)
return path
}
}
struct ContentView: View {
#State var width: CGFloat = 100
#State var height: CGFloat = 100
var body: some View {
VStack {
RectangleView(height: height, width: width)
.animation(.linear(duration: 30).repeatForever(), value: height)
.animation(.spring(response: 1, dampingFraction: 0.5, blendDuration: 1), value: width)
.onAppear(perform: {
height = 200
})
Button("Add width via spring", action: {
width += 10
})
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Expected: Rectangle continues to grow vertically linearly over 30 seconds after clicking 'add width' button.
Actual: Rectangle springs vertically over 1 second along with the width.

SwiftUI - Dynamic LazyHGrid row height

I'm creating vertical layout which has scrollable horizontal LazyHGrid in it. The problem is that views in LazyHGrid can have different heights (primarly because of dynamic text lines) but the grid always calculates height of itself based on first element in grid:
What I want is changing size of that light red rectangle based on visible items, so when there are smaller items visible it should look like this:
and when there are bigger items it should look like this:
This is code which results in state on the first image:
struct TestView: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem()],
alignment: .top,
spacing: 16
) {
Color.red
.frame(width: 64, height: 24)
ForEach(Array(0...10), id: \.self) { value in
Color.red
.frame(width: 64, height: CGFloat.random(in: 32...92))
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}
}
}
Something similar what I want can be achieved by this:
extension View {
func readSize(edgesIgnoringSafeArea: Edge.Set = [], onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
SwiftUI.Color.clear
.preference(key: ReadSizePreferenceKey.self, value: geometryProxy.size)
}.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
)
.onPreferenceChange(ReadSizePreferenceKey.self) { size in
DispatchQueue.main.async { onChange(size) }
}
}
}
struct ReadSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct Size: Equatable {
var height: CGFloat
var isValid: Bool
}
struct TestView: View {
#State private var sizes = [Int: Size]()
#State private var height: CGFloat = 32
static let values: [(Int, CGFloat)] =
(0...3).map { ($0, CGFloat(32)) }
+ (4...10).map { ($0, CGFloat(92)) }
var body: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem(.fixed(height))],
alignment: .top,
spacing: 16
) {
ForEach(Array(Self.values), id: \.0) { value in
Color.red
.frame(width: 300, height: value.1)
.readSize { sizes[value.0]?.height = $0.height }
.onAppear {
if sizes[value.0] == nil {
sizes[value.0] = Size(height: .zero, isValid: true)
} else {
sizes[value.0]?.isValid = true
}
}
.onDisappear { sizes[value.0]?.isValid = false }
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}.onChange(of: sizes) { sizes in
height = sizes.filter { $0.1.isValid }.map { $0.1.height }.max() ?? 32
}
}
}
... but as you see its kind of laggy and a little bit complicated, isn't there better solution? Thank you everyone!
The height of a row in a LazyHGrid is driven by the height of the tallest cell. According to the example you provided, the data source will only show a smaller height if it has only a small size at the beginning.
Unless the first rendering will know that there are different heights, use the larger value as the height.
Is your expected UI behaviour that the height will automatically switch? Or use the highest height from the start.

SwiftUI, How to stop GridItem from expanding

I am following the course CS193p from Stanford, since im learning swift on my own and all the course material has been released (I am not enrolled).
Im currently working on the midterm projekt in which i have to develop the game "Set".
The cards should be displayed adaptively on the screen so that the cards get smaller or bigger when cards are added or removed to the board. The professor has released a struct which is supposed to handle this. (This works)
One of the requirements for the assignment is that all the cards should be equally sized. And this is where i am stuck. The cards with 3 shapes on them are overflowing into the other cards:
Screenshot
Here is the code:
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
VStack {
let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
}
Spacer(minLength: 0)
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
var columnCount = 1
var rowCount = itemCount
repeat {
let itemWidth = size.width / CGFloat(columnCount)
let itemHeight = itemWidth / itemAspectRatio
if CGFloat(rowCount) * itemHeight < size.height {
break
}
columnCount += 1
rowCount = (itemCount + (columnCount - 1)) / columnCount
} while columnCount < itemCount
if columnCount > itemCount {
columnCount = itemCount
}
return floor(size.width / CGFloat(columnCount))
}
}
I have tried using .scaleToFit, .frame, and changing diffrent sizes across the struct but nothing have worked so far.
Using .clipped is the closest i got since all elements now are the same size, except for the fact that the bottom of the blue card gets cut off.
I know i have to fix the height of the GridItem somehow but i cant figure it out.
Any help would be very much appreciated!
CardView() code:
struct CardView: View {
let card: Model.Card
var body: some View {
GeometryReader { geometry in
ZStack{
let shape = RoundedRectangle(cornerRadius: 10)
if !card.isMatched {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 4).foregroundColor(card.isSelected ? .red : .blue)
//} else if card.isMatched {
// shape.opacity(0)
//} else {
// shape.fill()
}
VStack {
Spacer(minLength: 0)
ForEach(0..<card.numberOfShapes) { index in
cardShape().frame(height: geometry.size.height/5)
}
Spacer(minLength: 0)
}.padding()
.foregroundColor(setColor())
.aspectRatio(CGFloat(6.0/8.0), contentMode: .fit)
}
}
}

SwiftUI | GeometryReader: Smooth resizable Header when scrolling through List

I am new to SwiftUI and I want to recreate the Contact-Card View from the Contacts App.
I am struggling to resize the Image on the top smoothly when scrolling in the List below.
I have tried using GeometryReader, but ran into issues there.
When scrolling up for example, the picture size just jumps abruptly to the minimumPictureSize I have specified. The opposite happens when scrolling up: It stops resizing abruptly when I stop scrolling.
Wanted behaviour: https://gifyu.com/image/Ai04
Current behaviour: https://gifyu.com/image/AjIc
struct SwiftUIView: View {
#State var startOffset: CGFloat = 0
#State var offset: CGFloat = 0
var minPictureSize: CGFloat = 100
var maxPictureSize: CGFloat = 200
var body: some View {
VStack {
Image("person")
.resizable()
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
.mask(Circle())
Text("startOffset: \(startOffset)")
Text("offset: \(offset)")
List {
Section {
Text("Top Section")
}.overlay(
GeometryReader(){ geometry -> Color in
let rect = geometry.frame(in: .global)
if startOffset == 0 {
DispatchQueue.main.async {
startOffset = rect.minY
}
}
DispatchQueue.main.async {
offset = rect.minY - startOffset
}
return Color.clear
}
)
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}.listStyle(InsetGroupedListStyle())
}.navigationBarHidden(true)
}
}
Not a perfect solution, but you could separate the header and List into 2 layers in a ZStack:
struct SwiftUIView: View {
#State var startOffset: CGFloat!
#State var offset: CGFloat = 0
let minPictureSize: CGFloat = 100
let maxPictureSize: CGFloat = 200
var body: some View {
ZStack(alignment: .top) {
if startOffset != nil {
List {
Section {
Text("Top Section")
} header: {
// Leave extra space for `List` so it won't clip its content
Color.clear.frame(height: 100)
}
.overlay {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
offset = frame.minY - startOffset
}
return Color.clear
}
}
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}
.listStyle(InsetGroupedListStyle())
.padding(.top, startOffset-100) // Make up extra space
}
VStack {
Circle().fill(.secondary)
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
Text("startOffset: \(startOffset ?? -1)")
Text("offset: \(offset)")
}
.frame(maxWidth: .infinity)
.padding(.bottom, 20)
.background(Color(uiColor: UIColor.systemBackground))
.overlay {
if startOffset == nil {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
startOffset = frame.maxY + // Original small one
maxPictureSize - minPictureSize -
frame.minY // Top safe area height
}
return Color.clear
}
}
}
}
.navigationBarHidden(true)
}
}
Notice that Color.clear.frame(height: 100) and .padding(.top, startOffset-100) are intended to leave extra space for List to avoid being clipped, which will cause the scroll bar get clipped. Alternatively, UIScrollView.appearance().clipsToBounds = true will work. However, it'll make element which moves outside the bounds of List disappear. Don't know if it's a bug.

create a #State var area, that is based on two #State var width, an #State var height

How do you combine multiple state variables to form another?
I want to change the value of height OR width by some user interaction, and have everything in the view update accordingly. So the height OR width would change, and the area would change.
I imagine it would look something like this
#State var width: CGFloat = 50.0
#State var height: CGFloat = 100.0
#State var area: CGFloat // somehow equal to width*height
current solution is just calling a func
func area() -> CGFloat {
width * height
}
Don't make area #State; just make it a computed variable:
#State var height: CGFloat = 50.0
#State var width: CGFloat = 100.0
var area: CGFloat {
width * height
}
var body: some View {
VStack {
Text("Width: \(width)")
Text("Height: \(height)")
Text("Area \(area)")
Button(action: {
self.height *= 2
}) {
Text("Double height")
}
Button(action: {
self.width += 10
}) {
Text("Add 10 to width")
}
}
}
I added some code to illustrate that if width or height changes, area will change too, because width or height changing cause the view to be redrawn since they are #State. Since area is computed, when the view is redrawn, area is determined to be the product of the updated width and height values. Doing it as a function like you said in your current solution should also work, though.
If you want area to be #State so that you can pass it to other views as a Binding, do this:
struct ContentView: View {
#State var height: CGFloat = 50.0
#State var width: CGFloat = 100.0
var area: Binding<CGFloat> {
Binding(get: {
self.height * self.width
}) { (newVal) in
}
}
var body: some View {
VStack {
Text("Width: \(width)")
Text("Height: \(height)")
Text("Area \(area.wrappedValue)")
BindingView(num: area)
BindingView(num: $height)
Button(action: {
self.height *= 2
}) {
Text("Double height")
}
Button(action: {
self.width += 10
}) {
Text("Add 10 to width")
}
}
}
struct BindingView: View {
#Binding var num: CGFloat
var body: some View {
Text("Binding number: \(num)")
}
}
I created BindingView as an example of how to use bindings in different ways. For #State variables, you effectively turn them into a Binding by adding the $ prefix, but since area is explicitly Binding, you do not need the $. Also to access the value inside the Binding, you just do the variable .wrappedValue.