SwiftUI - Dynamic LazyHGrid row height - swift

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.

Related

Fading Text ViewModifier to simulate truncationMode .byWordWrapping and apply fading mask

I am trying to create a ViewModifier called .fade that can be used on SwiftUI.Text and simulates a fading effect with a truncation mode of .byWordWrapping known from UIKit. In addition I apply a fading mask to the last line of the Text View.
In my example code for a static View everything works as expected, but for some dynamic changes the modifier creates a wrong layout or does not update properly.
My actual problems are:
changing the lineLimit dynamically
Everything works if going from a larger lineLimit to a smaller one, but the other way going from smaller to larger values is not working
Changing the size of the parent Stack does not resize the view if the view is already faded. Meaning the view exceeds the parent bounds of width: 300 (Can be simulated in the TestView by showing the image while a text is faded.)
Helper ViewModifer to determine the size of a view:
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
public extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
The FadingViewModifer itself:
private struct FadingTextViewModifier: ViewModifier {
#State private var intrinsicSize = CGSize.zero
#State private var truncatedSize = CGSize.zero
#State private var isTruncated = false
let lineLimit: Int?
let font: UIFont
let useMaxWidth: Bool
let alignment: Alignment
let isTruncatedAction: ((Bool) -> Void)?
var maxWidth: CGFloat? { useMaxWidth ? .infinity : nil }
func body(content: Content) -> some View {
let truncatedView = content
.font(Font(font))
.lineLimit(lineLimit)
.readSize { size in
truncatedSize = size
isTruncated = truncatedSize != intrinsicSize
isTruncatedAction?(isTruncated)
}
let intrinsicView = content
.font(Font(font))
.fixedSize(horizontal: false, vertical: true)
.readSize { size in
intrinsicSize = size
isTruncated = truncatedSize != intrinsicSize
isTruncatedAction?(isTruncated)
}
let view = content
.font(Font(font))
.frame(maxWidth: maxWidth, alignment: alignment)
.background(truncatedView.hidden(), alignment: alignment)
.background(intrinsicView.hidden(), alignment: alignment)
Group {
if isTruncated {
content
.font(Font(font))
.fixedSize(horizontal: lineLimit == 1, vertical: true)
.frame(width: truncatedSize.width, height: truncatedSize.height, alignment: alignment)
.frame(maxWidth: maxWidth, alignment: alignment)
.clipped()
.mask(maskView)
.background(Color.yellow)
.background(view.hidden())
} else {
view
}
}
.background(Color.purple)
}
}
Extension to create the maskView that is used for the fading:
private extension FadingTextViewModifier {
var ellipsisWidth: CGFloat { lineLimit == 1 ? 0 : 15 }
var fadeOutWidth: CGFloat { 50 }
var maskStart: UnitPoint {
let width = truncatedSize.width
let visibleWidth = width - ellipsisWidth - fadeOutWidth
let startX: CGFloat = width > 0 && visibleWidth > 0 ? visibleWidth / width : 0.8
return .init(x: startX, y: 0.5)
}
var maskEnd: UnitPoint {
let width = truncatedSize.width
let endX: CGFloat = width > 0 && width > ellipsisWidth ? (width - ellipsisWidth) / width : 1
return .init(x: endX, y: 0.5)
}
// Only apllied to the last line.
var maskView: some View {
VStack(spacing: 0) {
Rectangle()
LinearGradient(colors: [.white, .clear], startPoint: maskStart, endPoint: maskEnd)
.frame(height: font.lineHeight)
}
}
}
The actual ViewModifier extension:
extension Text {
/// Fades the `Text` if needed.
/// - Parameters:
/// - lineLimit: Defines the lineLimit of your text. Modifies by default the lineLimit to 1 instead of nil
/// - font: Needs an UIFont to determine the lineHeight for the masking effect. Directly applies the font to the Text.
/// - useMaxWidth: Only relevant for lineLimit of 1. By default the text view uses maxWidth .infinity to avoid a fading effect, although it would look like space is available.
/// - alignment: Specifies the text alignment inside the frame
/// - isTruncatedAction: Returns if the text is truncated or not. Use this to customize your view. e.g. a "show more"-button
func fade(lineLimit: Int? = 1,
font: UIFont = UIFont.systemFont(ofSize: 16),
useMaxWidth: Bool = true,
alignment: Alignment = .topLeading,
isTruncatedAction: ((Bool) -> Void)? = nil) -> some View {
modifier(FadingTextViewModifier(lineLimit: lineLimit,
font: font,
useMaxWidth: useMaxWidth,
alignment: alignment,
isTruncatedAction: isTruncatedAction))
}
}
A testView to simulate the problems:
struct FadingTextTest: View {
#State var withImage = false
#State var useLongText = false
#State var lineLimit = 1
let shortText = "This is a short text!"
let longText = "This is a much much much much much much much much much much longer text!"
var text: String {
useLongText ? longText : shortText
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Button("activate image") { withImage.toggle() }
Button("use long text") { useLongText.toggle() }
Stepper("Line Limit \(lineLimit)", value: $lineLimit)
.padding(.bottom, 50)
// Single Fading Text that does not need to adapt to size changes of the parent dynamically
Text(text)
.fade(lineLimit: lineLimit)
// Text that needs to adapt dynamically to width changes inside a Stack
HStack(spacing: 0) {
Text(text)
.fade(lineLimit: lineLimit)
if withImage {
Image(systemName: "star")
.resizable()
.frame(width: 20, height: 20)
}
}
// Text that fades at intrinsic width
Text(text)
.fade(lineLimit: lineLimit, useMaxWidth: false)
// Text without fading, but with lineLimit
Text(text)
.font(Font(UIFont.systemFont(ofSize: 16)))
.lineLimit(lineLimit)
.frame(maxWidth: .infinity, alignment: .topLeading)
// Text without any effect
Text(text)
.font(Font(UIFont.systemFont(ofSize: 16)))
.frame(maxWidth: .infinity, alignment: .topLeading)
Spacer()
}
.frame(maxHeight: .infinity)
.background(Color.green)
.frame(width: 300)
}
}

How to implement scrolling bottom down when we got to the beginning of the content, inside the scrollview using pure SwiftUI?

Here is my attempt to implement this functionality, I also tried to solve it through UIKit, it worked, but I ran into problems with dynamically changing the content of SwiftUI, which was inside UIScrollView. More precisely, the problem was in changing the height of the container
https://imgur.com/a/6du73pt
import SwiftUI
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ContentView: View {
#State private var offset: CGFloat = 300
var body: some View {
ZStack {
Color.yellow.ignoresSafeArea()
ScrollView(.vertical) {
ForEach(0..<100, id: \.self) { _ in
Color.red
.frame(width: 250, height: 125, alignment: .center)
}
.overlay(
GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self, value: offset)
.frame(width: 0, height: 0, alignment: .center)
})
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
if value >= 0 {
offset = value + 300
}
}
.gesture(DragGesture()
.onChanged({ value in
print("scrooll")
print(value)
})
)
}
.offset(y: offset)
.gesture(DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onChanged({ value in
offset = value.translation.height + 300
}))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Below is an example of how you can lock the ScrollView when you are at the top, and then allow the DragGesture to operate instead of scroll. I removed your PreferenceKey as it was not necessary. I also used frame reader to determine where in the scroll view the top cell was. Code is extensively commented.
struct ScrollViewWithPulldown: View {
#State private var offset: CGFloat = 300
#State private var scrollEnabled = true
#State private var cellRect: CGRect = .zero
// if the top of the cell is in view, origin.y will be greater than or equal to zero
var topInView: Bool {
cellRect.origin.y >= 0
}
var body: some View {
ZStack {
Color.yellow.ignoresSafeArea()
ScrollView {
ForEach(0..<100, id: \.self) { id in
Color.red
.id(id)
.frame(width: 250, height: 125, alignment: .center)
// This is inspired by https://www.fivestars.blog/articles/swiftui-share-layout-information/
.copyFrame(in: .named("scroll"), to: $cellRect)
.onChange(of: cellRect) { _ in
if id == 0 { // insure the first view however you need to
if topInView {
scrollEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollEnabled = true
}
} else {
scrollEnabled = true
}
}
}
}
}
.disabled(!scrollEnabled)
.coordinateSpace(name: "scroll")
}
.offset(y: offset)
.gesture(DragGesture()
.onChanged({ value in
// Scrolling down
if value.translation.height > 0 && topInView {
scrollEnabled = false
print("scroll locked")
print(value)
} else { // Scrolling up
scrollEnabled = true
print("scroll up")
print(value)
}
})
.onEnded({ _ in
scrollEnabled = true
})
)
}
}
A view extension inspired by FiveStar Blog:
extension View {
func readFrame(in space: CoordinateSpace, onChange: #escaping (CGRect) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: FrameInPreferenceKey.self, value: geometryProxy.frame(in: space))
}
)
.onPreferenceChange(FrameInPreferenceKey.self, perform: onChange)
}
func copyFrame(in space: CoordinateSpace, to binding: Binding<CGRect>) -> some View {
self.readFrame(in: space) { frame in
binding.wrappedValue = frame
}
}
}

How Do I get uniform view size when using Image and SFSymbols in SwiftUI?

Here is my View:
I want all boxes (the red rectangles) to be the same size (heights all equal to each other and widths all equal to each other). They don't need to be square.
When I create views using Image(systemname:) they have different intrinsic sizes. How do I make them the same size without hard-coding the size.
struct InconsistentSymbolSizes: View {
let symbols = [ "camera", "comb", "diamond", "checkmark.square"]
var body: some View {
HStack(spacing: 0) {
ForEach(Array(symbols), id: \.self) { item in
VStack {
Image(systemName: item).font(.largeTitle)
}
.padding()
.background(.white)
.border(.red)
}
}
.border(Color.black)
}
}
If you want to normalize the sizes, you could use a PreferenceKey to measure the largest size and make sure that all of the other sizes expand to that:
struct ContentView: View {
let symbols = [ "camera", "comb", "diamond", "checkmark.square"]
#State private var itemSize = CGSize.zero
var body: some View {
HStack(spacing: 0) {
ForEach(Array(symbols), id: \.self) { item in
VStack {
Image(systemName: item).font(.largeTitle)
}
.padding()
.background(GeometryReader {
Color.clear.preference(key: ItemSize.self,
value: $0.frame(in: .local).size)
})
.frame(width: itemSize.width, height: itemSize.height)
.border(.red)
}.onPreferenceChange(ItemSize.self) {
itemSize = $0
}
}
.border(Color.black)
}
}
struct ItemSize: PreferenceKey {
static var defaultValue: CGSize { .zero }
static func reduce(value: inout Value, nextValue: () -> Value) {
let next = nextValue()
value = CGSize(width: max(value.width,next.width),
height: max(value.height,next.height))
}
}

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.

SwiftUI set position to center of different view

I have two different views, one red rect and one black rect that is always positioned on the bottom of the screen. When I click the red rect it should position itself inside the other rect.
Currently the red rect is positioned statically: .position(x: self.tap ? 210 : 50, y: self.tap ? 777 : 50). Is there a way to replace the 210 and 777 dynamically to the position of the black rects center position?
I know that I can use the GeometryReader to get the views size, but how do I use that size to position a different view? Would this even be the right way?
struct ContentView: View {
#State private var tap = false
var body: some View {
ZStack {
VStack {
Spacer()
RoundedRectangle(cornerRadius: 10)
.frame(maxWidth: .infinity, maxHeight: 50, alignment: .center)
}
.padding()
VStack {
ZStack {
Rectangle()
.foregroundColor(.red)
Text("Click me")
.fontWeight(.light)
.foregroundColor(.white)
}
.frame(width: 50, height: 50)
.position(x: self.tap ? 210 : 50, y: self.tap ? 777 : 50)
.onTapGesture {
withAnimation {
self.tap.toggle()
}
}
}
}
}
}
First define some structure where to store .center position of some View
struct PositionData: Identifiable {
let id: Int
let center: Anchor<CGPoint>
}
The build-in mechanism to save such data and expose them to parent View is to set / read (or react) on values which conforms to PreferenceKey protocol.
struct Positions: PreferenceKey {
static var defaultValue: [PositionData] = []
static func reduce(value: inout [PositionData], nextValue: () -> [PositionData]) {
value.append(contentsOf: nextValue())
}
}
To be able to read the center positions of View we can use well known and widely discussed GeometryReader. I define my PositionReader as a View and here we can simply save its center position in our preferences for further usage. There is no need to translate the center to different coordinate system. To identify the View its tag value must be saved as well
struct PositionReader: View {
let tag: Int
var body: some View {
// we don't need geometry reader at all
//GeometryReader { proxy in
Color.clear.anchorPreference(key: Positions.self, value: .center) { (anchor) in
[PositionData(id: self.tag, center: anchor)]
}
//}
}
}
To demonstrate how to use all this together see next simple application (copy - paste - run)
import SwiftUI
struct PositionData: Identifiable {
let id: Int
let center: Anchor<CGPoint>
}
struct Positions: PreferenceKey {
static var defaultValue: [PositionData] = []
static func reduce(value: inout [PositionData], nextValue: () -> [PositionData]) {
value.append(contentsOf: nextValue())
}
}
struct PositionReader: View {
let tag: Int
var body: some View {
Color.clear.anchorPreference(key: Positions.self, value: .center) { (anchor) in
[PositionData(id: self.tag, center: anchor)]
}
}
}
struct ContentView: View {
#State var tag = 0
var body: some View {
ZStack {
VStack {
Color.green.background(PositionReader(tag: 0))
.onTapGesture {
self.tag = 0
}
HStack {
Rectangle()
.foregroundColor(Color.red)
.aspectRatio(1, contentMode: .fit)
.background(PositionReader(tag: 1))
.onTapGesture {
self.tag = 1
}
Rectangle()
.foregroundColor(Color.red)
.aspectRatio(1, contentMode: .fit)
.background(PositionReader(tag: 2))
.onTapGesture {
self.tag = 2
}
Rectangle()
.foregroundColor(Color.red)
.aspectRatio(1, contentMode: .fit)
.background(PositionReader(tag: 3))
.onTapGesture {
self.tag = 3
}
}
}
}.overlayPreferenceValue(Positions.self) { preferences in
GeometryReader { proxy in
Rectangle().frame(width: 50, height: 50).position( self.getPosition(proxy: proxy, tag: self.tag, preferences: preferences))
}
}
}
func getPosition(proxy: GeometryProxy, tag: Int, preferences: [PositionData])->CGPoint {
let p = preferences.filter({ (p) -> Bool in
p.id == tag
})[0]
return proxy[p.center]
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The code is almost self explanatory, we use .background(PositionReader(tag:)) to save the center position of View (this could be avoided by applying .anchorPreference directly on the View) and
.overlayPreferenceValue(Positions.self) { preferences in
GeometryReader { proxy in
Rectangle().frame(width: 50, height: 50).position( self.getPosition(proxy: proxy, tag: self.tag, preferences: preferences))
}
}
is used to create small black rectangle which will position itself at center of other Views. Just tap anywhere in green or red rectangles, and the black one will move immediately :-)
Here is view of this sample application running.
Here is possible approach (with a bit simplified your initial snapshot and added some convenient View extension).
Tested with Xcode 11.2 / iOS 13.2
extension View {
func rectReader(_ binding: Binding<CGRect>, in space: CoordinateSpace) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: space)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
struct ContentView: View {
#State private var tap = false
#State private var bottomRect: CGRect = .zero
var body: some View {
ZStack(alignment: .bottom) {
RoundedRectangle(cornerRadius: 10)
.frame(maxWidth: .infinity, maxHeight: 50, alignment: .center)
.padding()
.rectReader($bottomRect, in: .named("board"))
Rectangle()
.foregroundColor(.red)
.overlay(Text("Click me")
.fontWeight(.light)
.foregroundColor(.white)
)
.frame(width: 50, height: 50)
.position(x: self.tap ? bottomRect.midX : 50,
y: self.tap ? bottomRect.midY : 50)
.onTapGesture {
withAnimation {
self.tap.toggle()
}
}
}.coordinateSpace(name: "board")
}
}