SwiftUI | GeometryReader: Smooth resizable Header when scrolling through List - swift

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.

Related

Dismiss gesture with scale effect

When I add a drag gesture to dismiss my "big view" it scales to the amount of the gesture but if it dismisses, the view jumps to its origin scale and animates the view to the "small view".
Here is my example Code:
`
struct TestView: View {
#State var showBigView = false
#Namespace private var animationNameSpace
#State var gestureOffset: CGSize = .zero
var body: some View {
ZStack {
if !showBigView {
Image(systemName: "tshirt")
.resizable()
.scaledToFit()
.foregroundColor(.red)
.matchedGeometryEffect(id: "circel", in: animationNameSpace)
.transition(.scale(scale: 1))
.frame(width: 100, height: 100)
.onTapGesture {
withAnimation {
showBigView = true
}
}
} else {
Image(systemName: "tshirt")
.resizable()
.scaledToFit()
.foregroundColor(.red)
.matchedGeometryEffect(id: "circel", in: animationNameSpace)
.transition(.scale(scale: 1))
.frame(width: 300, height: 300)
.scaleEffect(abs(gestureOffset.height / 1000 - 1))
.gesture(DragGesture().onChanged {
guard $0.translation.height > 0 else { return }
self.gestureOffset = $0.translation
if abs($0.translation.height) > 150 {
withAnimation {
showBigView = false
gestureOffset = .zero
}
}
})
}
}
}
}
`
It should not jump when the view dismisses with a specific scaleEffect.
I believe you're trying to:
Show the tee shirt image in a 100 by 100 frame.
When tapped, it grows to a 300 by 300 frame with animation.
Dragging downward shrinks the image. When the drag height exceeds 150 points, the image shrinks to its original 100 by 100 frame with animation.
This approach works:
struct TestView: View {
#State private var isBig = false
#State private var frame = TestView.smallFrame
#State private var scale = 1.0
static private let smallFrame = 100.0
static private let bigFrame = 300.0
var body: some View {
Image(systemName: "tshirt")
.resizable()
.scaledToFit()
.frame(width: frame, height: frame)
.scaleEffect(scale)
.foregroundColor(.red)
.onTapGesture {
if !isBig {
withAnimation {
frame = TestView.bigFrame
}
isBig = true
}
}
.gesture(DragGesture().onChanged {
let dragHeight = $0.translation.height
if isBig && dragHeight > 0 {
if dragHeight >= 150 {
frame = TestView.bigFrame * scale
scale = 1
withAnimation {
frame = TestView.smallFrame
}
isBig = false
} else {
scale = 1 - (dragHeight / 1000)
}
}
}.onEnded { _ in
withAnimation {
scale = 1
}
})
}
}
It's not necessary to create two separate images and synchronize them with matchedGeometryEffect. Instead, you can use your #State boolean to control whether or not gestures have any effect.

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.

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

Swipe back gesture like in without navigationView in SwiftUI

I am trying to create a simply game in SwiftUI and im trying to get a swipe back gesture similar to that on navigationView but without putting my view inside a navigationView. Since this is a game, adding naviagationView will look out of place.
This is what I have so far:
struct SwipingView : View {
#State private var dragAmount = CGSize.zero
#GestureState private var position = CGSize.zero
func addToPosition(translation:CGSize) -> CGSize {
return CGSize(width: dragAmount.width + translation.width, height: dragAmount.height + translation.height)
}
var body: some View{
return ZStack(){
Rectangle().fill(Color.red).frame(width: 100, height:400).scaleEffect(x:5,y:1,anchor: .leading)
.offset(x: 190)
.offset(x: addToPosition(translation: position).width )
.gesture(
DragGesture(minimumDistance: 20)
.updating(self.$position){ value, state, translation in
state = value.translation
}
.onEnded{ value in
if value.translation.width > 50 {
guard position.width + self.addToPosition(translation: CGSize(width:330, height:0)).width < 330-1 else {
return print("too far right")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:330, height:0))
} else {
guard position.width + self.addToPosition(translation: CGSize(width:-330, height:0)).width > -330-1 else {
return print("too far left")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:-330, height:0))
}
}
)
}.animation(Animation.linear)
}
}
I'm still new to swift so there is likely something obvious im missing even though I've looked through stack overflow and couldn't find exactly what im looking for.
This is what it looks like. As you can see, I can swipe the view away from anywhere on the view, but I'd like to only swipe away on the left edge or even just the first 5% on the left.
Here is possible solution - the idea is to attach gesture to overlay that is as wide at the left as needed. Tested with Xcode 12 / iOS 14
Note: on demo the active area made Color.blue instead of Color.clear for better visibility
struct SwipingView : View {
#State private var dragAmount = CGSize.zero
#GestureState private var position = CGSize.zero
func addToPosition(translation:CGSize) -> CGSize {
return CGSize(width: dragAmount.width + translation.width, height: dragAmount.height + translation.height)
}
var body: some View{
return ZStack(){
Rectangle().fill(Color.red).frame(width: 100, height:400).scaleEffect(x:5,y:1,anchor: .leading)
.overlay(Color.clear.frame(width: 40) // << make width as needed
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 20)
.updating(self.$position){ value, state, translation in
state = value.translation
}
.onEnded{ value in
if value.translation.width > 50 {
guard position.width + self.addToPosition(translation: CGSize(width:330, height:0)).width < 330-1 else {
return print("too far right")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:330, height:0))
} else {
guard position.width + self.addToPosition(translation: CGSize(width:-330, height:0)).width > -330-1 else {
return print("too far left")
}
self.dragAmount = self.addToPosition(translation: CGSize(width:-330, height:0))
}
}
), alignment: .leading)
.offset(x: 190)
.offset(x: addToPosition(translation: position).width )
}.animation(Animation.linear)
}
}

How to make a swipeable view with SwiftUI

I tried to make a SWIFTUI View that allows card Swipe like action by using gesture() method. But I can't figure out a way to make view swipe one by one. Currently when i swipe all the views are gone
import SwiftUI
struct EventView: View {
#State private var offset: CGSize = .zero
#ObservedObject var randomView: EventViewModel
var body: some View {
ZStack{
ForEach(randomView.randomViews,id:\.id){ view in
view
.background(Color.randomColor)
.cornerRadius(8)
.shadow(radius: 10)
.padding()
.offset(x: self.offset.width, y: self.offset.height)
.gesture(
DragGesture()
.onChanged { self.offset = $0.translation }
.onEnded {
if $0.translation.width < -100 {
self.offset = .init(width: -1000, height: 0)
} else if $0.translation.width > 100 {
self.offset = .init(width: 1000, height: 0)
} else {
self.offset = .zero
}
}
)
.animation(.spring())
}
}
}
}
struct EventView_Previews: PreviewProvider {
static var previews: some View {
EventView(randomView: EventViewModel())
}
}
struct PersonView: View {
var id:Int = Int.random(in: 1...1000)
var body: some View {
VStack(alignment: .center) {
Image("testBtn")
.clipShape(/*#START_MENU_TOKEN#*/Circle()/*#END_MENU_TOKEN#*/)
Text("Majid Jabrayilov")
.font(.title)
.accentColor(.white)
Text("iOS Developer")
.font(.body)
.accentColor(.white)
}.padding()
}
}
With this piece of code, when i swipe the whole thing is gone
Basically your code tells every view to follow offset, while actually you want only the top one move. So firstly I'd add a variable that'd hold current index of the card and a method to calculate it's offset:
#State private var currentCard = 0
func offset(for i: Int) -> CGSize {
return i == currentCard ? offset : .zero
}
Secondly, I found out that if we just leave it like that, on the next touch view would get offset of the last one (-1000, 0) and only then jump to the correct location, so it looks just like previous card decided to return instead of the new one. In order to fix this I added a flag marking that card has just gone, so when we touch it again it gets right location initially. Normally, we'd do that in gesture's .began state, but we don't have an analog for that in swiftUI, so the only place to do it is in .onChanged:
#State private var didJustSwipe = false
DragGesture()
.onChanged {
if self.didJustSwipe {
self.didJustSwipe = false
self.currentCard += 1
self.offset = .zero
} else {
self.offset = $0.translation
}
}
In .onEnded in the case of success we assign didJustSwipe = true
So now it works perfectly. Also I suggest you diving your code into smaller parts. It will not only improve readability, but also save some compile time. You didn't provide an implementation of EventViewModel and those randomViews so I used rectangles instead. Here's your code:
struct EventView: View {
#State private var offset: CGSize = .zero
#State private var currentCard = 0
#State private var didJustSwipe = false
var randomView: some View {
return Rectangle()
.foregroundColor(.green)
.cornerRadius(20)
.frame(width: 300, height: 400)
.shadow(radius: 10)
.padding()
.opacity(0.3)
}
func offset(for i: Int) -> CGSize {
return i == currentCard ? offset : .zero
}
var body: some View {
ZStack{
ForEach(currentCard..<5, id: \.self) { i in
self.randomView
.offset(self.offset(for: i))
.gesture(self.gesture)
.animation(.spring())
}
}
}
var gesture: some Gesture {
DragGesture()
.onChanged {
if self.didJustSwipe {
self.didJustSwipe = false
self.currentCard += 1
self.offset = .zero
} else {
self.offset = $0.translation
}
}
.onEnded {
let w = $0.translation.width
if abs(w) > 100 {
self.didJustSwipe = true
let x = w > 0 ? 1000 : -1000
self.offset = .init(width: x, height: 0)
} else {
self.offset = .zero
}
}
}
}