Switching a #State property to a #Binding property interferes with animation - swift

I am currently building a bottom sheet UI like Apple Map's and I encountered myself with some weird bug that I am struggling to fix.
The following snippet represents the bottom sheet and includes a ready-to-go preview. You can try it out and see that everything works as expected in both the simulator and in XCode's preview engine.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#State private var opened = false
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
Now we switch the opened property to be a Binding property so that the parent view can know the state of the bottom sheet.
Here is the code with the changes.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Binding property that shares the state of the sheet to the parent view
#Binding private var opened: Bool
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
opened: Binding<Bool>,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self._opened = opened
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
opened: self.$opened,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
}
However, if you run the preview in XCode you'll see that when the drag gesture stops, instead of the sheet snapping back to the "opened" state from where we lift the finger off, it does the animation from its initial position.
Weirdly enough, if you try to run this in an iPhone or in the simulator, it behaves as expected, as in the first example. Although I've tried to use the code in a more "complex" app (it has a TabView and some few more things) and the same bug appears.
Any ideas why this is happening?
Here is an example with the example view used in a real app tested in a real iPhone. You'll see that the bug is very noticeable.
Video: https://imgur.com/a/RFZbqbD

Actually I see here some misunderstanding that must be clarified and, probably, then initial solution should be rethink. Binding is not state shared to parent, it is link to parent's state holding source of truth, so your view becomes dependent on parent's capability to refresh it on state change, which is not always reliable (or stable, or persistent, etc.), especially in different view hierarchies (like, sheets, UIKit backend, etc.). Changing binding you do not refresh your view directly (as opposite to changes own state) even if your view depends on value in binding, but change parent state, which might do or might do not update your view back. Finalizing - what you've implied is not reliable approach by nature, and you actually observe this.
Alternate solution: use ObsevableObject/ObservedObject view model pattern.
Tested with Xcode 12.4 / iOS 14.4
import MapKit
class ReleaseGestureVM: ObservableObject {
#Published var opened: Bool = false
}
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
#ObservedObject var vm: ReleaseGestureVM
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.vm.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.vm.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
vm: ReleaseGestureVM,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.vm = vm
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#StateObject private var vm = ReleaseGestureVM()
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
vm: self.vm,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}

Related

How can I make a custom BlurMaterial using blur modifier in Swift?

I am trying to over come to making a custom BlurMaterial challenge, as you could read from question I am trying to make a custom BlurMaterial, the idea is simple, I am accessing the all available view and then trying to cut it and blur the cut part, but the issue happens when the blur radius goes up and up, the wired rectangular happens and it seems the blur got issue at edges the yellow rectangular, I think this issue could simply solved, as you can see the logic of codes works almost, need help to solve the wired rectangular issue and blur issue at edges the yellow rectangular when we increases the blur radius.
INFO: the issue of wired rectangular happens in macOS SwiftUI but the blur issue at edges the yellow rectangular happens in iOS and macOS SwiftUI. My target is macOS SwiftUI not just iOS SwiftUI.
struct ContentView: View {
#State private var radius: CGFloat = 8.0
#State private var opacity: CGFloat = 0.2
#State private var accentColorOfBlurMaterial: Color = Color.black.opacity(0.5)
#State private var backgroundColor: Color = Color.green
#State private var offset: CGSize = CGSize.zero
#State private var lastOffset: CGSize = CGSize.zero
#State private var blurSize: CGSize = CGSize(width: 200.0, height: 200.0)
var body: some View {
let baseView = VStack {
Button("Tap") { print("tapped!") }
swift
slider(label: "radius:", value: $radius, range: 0.0...50.0)
slider(label: "opacity:", value: $opacity, range: 0.0...1.0)
}
.padding()
.background(Color.yellow.cornerRadius(10.0))
.padding(50.0)
.background(backgroundColor)
.fixedSize()
return ZStack {
baseView
baseView
.blur(radius: radius)
.frame(width: blurSize.width, height: blurSize.height)
.offset(x: -offset.width, y: -offset.height)
.clipped()
.contentShape(Rectangle())
.overlay(accentColorOfBlurMaterial.opacity(opacity))
.border(Color.black, width: 5.0)
.offset(offset)
.gesture(gesture)
.onChange(of: radius, perform: { newValue in
print("blur radius =", newValue)
})
}
}
var swift: some View { return Image(systemName: "swift").resizable().scaledToFit().frame(width: 300.0).foregroundColor(Color.red) }
func slider(label: String, value: Binding<CGFloat>, range: ClosedRange<CGFloat>) -> some View {
return HStack { Text(label); Spacer(); Slider(value: value, in: range) }
}
private var gesture: some Gesture {
return DragGesture(minimumDistance: .zero, coordinateSpace: .global)
.onChanged { value in
offset = CGSize(width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height)
}
.onEnded { value in
lastOffset = CGSize(width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height)
offset = lastOffset
}
}
}

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

SwiftUI gestures in the toolbar area ignored

I'd like to implement a custom slider SwiftUI component and put it on the toolbar area of a SwiftUI Mac app. However the gesture of the control gets ignored as the system's window moving gesture takes priority. This problem does not occur for the system UI controls, like Slider or Button.
How to fix the code below so the slider works in the toolbar area as well, not just inside the window similar to the default SwiftUI components?
struct MySlider: View {
#State var offset: CGFloat = 0.0
var body: some View {
GeometryReader { gr in
let thumbSize = gr.size.height
let maxValue = (gr.size.width - thumbSize) / 2.0
let gesture = DragGesture(minimumDistance: 0).onChanged { v in
self.offset = max(min(v.translation.width, maxValue), -maxValue)
}
ZStack {
Capsule()
Circle()
.foregroundColor(Color.yellow)
.frame(width: thumbSize, height: thumbSize)
.offset(x: offset)
.highPriorityGesture(gesture)
}
}.frame(width: 100, height: 20)
}
}
struct ContentView: View {
#State var value = 0.5
var body: some View {
MySlider()
.toolbar {
MySlider()
Slider(value: $value).frame(width: 100, height: 20)
}.frame(width: 500, height: 100)
}
}
Looks like design limitation (or not implemented yet feature - Apple does not see such view as user interaction capable item).
A possible workaround is to wrap you active element into button style. The button as a container interpreted as user-interaction-able area but all drawing and handling is in your code.
Tested with Xcode 13.2 / macOS 12.2
Note: no changes in your slider logic
struct MySlider: View {
var body: some View {
Button("") {}.buttonStyle(SliderButtonStyle())
}
struct SliderButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
MySliderContent()
}
struct MySliderContent: View {
#State var offset: CGFloat = 0.0
var body: some View {
GeometryReader { gr in
let thumbSize = gr.size.height
let maxValue = (gr.size.width - thumbSize) / 2.0
let gesture = DragGesture(minimumDistance: 0).onChanged { v in
self.offset = max(min(v.translation.width, maxValue), -maxValue)
}
ZStack {
Capsule()
Circle()
.foregroundColor(Color.yellow)
.frame(width: thumbSize, height: thumbSize)
.offset(x: offset)
.highPriorityGesture(gesture)
}
}.frame(width: 100, height: 20)
}
}
}
}

How to correctly do up an adjustable split view in SwiftUI?

This is my first time trying out SwiftUI, and I am trying to create a SwiftUI view that acts as a split view, with an adjustable handle in the center of the two views.
Here's my current code implementation example:
struct ContentView: View {
#State private var gestureTranslation = CGSize.zero
#State private var prevTranslation = CGSize.zero
var body: some View {
VStack {
Rectangle()
.fill(Color.red)
.frame(height: (UIScreen.main.bounds.height / 2) + self.gestureTranslation.height)
RoundedRectangle(cornerRadius: 5)
.frame(width: 40, height: 3)
.foregroundColor(Color.gray)
.padding(2)
.gesture(DragGesture()
.onChanged({ value in
self.gestureTranslation = CGSize(width: value.translation.width + self.prevTranslation.width, height: value.translation.height + self.prevTranslation.height)
})
.onEnded({ value in
self.gestureTranslation = CGSize(width: value.translation.width + self.prevTranslation.width, height: value.translation.height + self.prevTranslation.height)
self.prevTranslation = self.gestureTranslation
})
)
Rectangle()
.fill(Color.green)
.frame(height: (UIScreen.main.bounds.height / 2) - self.gestureTranslation.height)
}
}
}
How it looks like now:
[
This kinda works, but when dragging the handle, it is very glitchy, and that it seems to require a lot of dragging to reach a certain point.
Please advice me what went wrong. Thank you.
See How to change the height of the object by using DragGesture in SwiftUI? for a simpler solution.
My version of that:
let MIN_HEIGHT = CGFloat(50)
struct DragViewSizeView: View {
#State var height: CGFloat = MIN_HEIGHT
var body: some View {
VStack {
Rectangle()
.fill(Color.red)
.frame(width: .infinity, height: height)
HStack {
Spacer()
Rectangle()
.fill(Color.gray)
.frame(width: 100, height: 10)
.cornerRadius(10)
.gesture(
DragGesture()
.onChanged { value in
height = max(MIN_HEIGHT, height + value.translation.height)
}
)
Spacer()
}
VStack {
Text("my o my")
Spacer()
Text("hoo hah")
}
}
}
}
struct DragTestView: View {
var body: some View {
VStack {
DragViewSizeView()
Spacer() // If comment this line the result will be as on the bottom GIF example
}
}
}
struct DragTestView_Previews: PreviewProvider {
static var previews: some View {
DragTestView()
}
}
From what I have observed, the issue seems to be coming from the handle being repositioned while being dragged along. To counteract that I have set an inverse offset on the handle, so it stays in place. I have tried to cover up the persistent handle position as best as I can, by hiding it beneath the other views (zIndex).
I hope somebody else got a better solution to this question. For now, this is all that I have got:
import PlaygroundSupport
import SwiftUI
struct SplitView<PrimaryView: View, SecondaryView: View>: View {
// MARK: Props
#GestureState private var offset: CGFloat = 0
#State private var storedOffset: CGFloat = 0
let primaryView: PrimaryView
let secondaryView: SecondaryView
// MARK: Initilization
init(
#ViewBuilder top: #escaping () -> PrimaryView,
#ViewBuilder bottom: #escaping () -> SecondaryView)
{
self.primaryView = top()
self.secondaryView = bottom()
}
// MARK: Body
var body: some View {
GeometryReader { proxy in
VStack(spacing: 0) {
self.primaryView
.frame(height: (proxy.size.height / 2) + self.totalOffset)
.zIndex(1)
self.handle
.gesture(
DragGesture()
.updating(self.$offset, body: { value, state, _ in
state = value.translation.height
})
.onEnded { value in
self.storedOffset += value.translation.height
}
)
.offset(y: -self.offset)
.zIndex(0)
self.secondaryView.zIndex(1)
}
}
}
// MARK: Computed Props
var handle: some View {
RoundedRectangle(cornerRadius: 5)
.frame(width: 40, height: 3)
.foregroundColor(Color.gray)
.padding(2)
}
var totalOffset: CGFloat {
storedOffset + offset
}
}
// MARK: - Playground
let splitView = SplitView(top: {
Rectangle().foregroundColor(.red)
}, bottom: {
Rectangle().foregroundColor(.green)
})
PlaygroundPage.current.setLiveView(splitView)
Just paste the code inside XCode Playground / Swift Playgrounds
If you found a way to improve my code please let me know.

Get the current position of ScrollView in SwiftUI?

I saw the position property but I think that is just used to set x and y, but I don't know what about recognizing current location.
Or totally how to use events income properties like onHover?
See What is Geometry Reader in SwiftUI?, specifically the discussion about GeometryGetter. If you place a GeometryGetter at the top of your ScrollView contents, it will emit its frame using the binding you pass to it. The origin of this frame will be the negative content offset of the scroll view.
In the following example you see how you can use GeometryReader to get the horizontal position of the content in the scroll view. However, I did not succeed yet in finding out how to set the scroll position. (Xcode 11.0 beta 6 (11M392q))
struct TimelineView: View {
#State private var posX: CGFloat = 0
var body: some View {
GeometryReader { geo in
VStack {
Text("\(self.posX)")
ScrollView(.horizontal, showsIndicators: true) {
VStack {
GeometryReader { innerGeo -> Text in
self.posX = innerGeo.frame(in: .global).minX
return Text("")
}
TimelineGridView()
}
}
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
}
}
}
where:
struct TimelineGridView: View {
var body: some View {
VStack {
ForEach(0...10, id: \.self) { rowIndex in
TimelineRowView()
}
}
}
}
struct TimelineRowView: View {
var body: some View {
HStack {
ForEach(0...100, id: \.self) { itemIndex in
TimelineCellView()
}
}
}
}
struct TimelineCellView: View {
var body: some View {
Rectangle()
.fill(Color.yellow)
.opacity(0.5)
.frame(width: 10, height: 10, alignment: .bottomLeading)
}
}
```
You can use TrackableScrollView by #maxnatchanon
Here is the code:
//
// TrackableScrollView.swift
// TrackableScrollView
//
// Created by Frad LEE on 2020/6/21.
// Copyright © 2020 Frad LEE. All rights reserved.
//
import SwiftUI
/// A trackable and scrollable view. Read [this link](https://medium.com/#maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec) for more.
///
/// The trackable scroll view displays its content within the trackable scrollable content region.
///
/// # Usage
///
/// ``` swift
/// struct ContentView: View {
/// #State private var scrollViewContentOffset = CGFloat(0) // Content offset available to use
///
/// var body: some View {
/// TrackableScrollView(.vertical, showIndicators: false, contentOffset: $scrollViewContentOffset) {
/// ...
/// }
/// }
/// }
/// ```
struct TrackableScrollView<Content>: View where Content: View {
let axes: Axis.Set
let showIndicators: Bool
#Binding var contentOffset: CGFloat
let content: Content
/// Creates a new instance that’s scrollable in the direction of the given axis and can show indicators while scrolling.
/// - Parameters:
/// - axes: The scrollable axes of the scroll view.
/// - showIndicators: A value that indicates whether the scroll view displays the scrollable component of the content offset, in a way that’s suitable for the platform.
/// - contentOffset: A value that indicates offset of content.
/// - content: The scroll view’s content.
init(_ axes: Axis.Set = .vertical, showIndicators: Bool = true, contentOffset: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self.axes = axes
self.showIndicators = showIndicators
_contentOffset = contentOffset
self.content = content()
}
var body: some View {
GeometryReader { outsideProxy in
ScrollView(self.axes, showsIndicators: self.showIndicators) {
ZStack(alignment: self.axes == .vertical ? .top : .leading) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
}
VStack {
self.content
}
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.contentOffset = value[0]
}
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
} else {
return outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
If you don't find any option. You can still use the standard UIScrollView with their delegates with UIViewRepresentable by making a separate struct an conforming to it.
More Detail on that you can find on the SwiftUI Tutorials:
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit
I created SwiftPackage for this purpose(Pure SwiftUI 👍)
With this lib, you can get position of ScrollView.
https://github.com/kazuooooo/PositionScrollView
Medium post
import Foundation
import SwiftUI
/// Extended ScrollView which can controll position
public struct MinimalHorizontalExample: View, PositionScrollViewDelegate {
/// Page size of Scroll
var pageSize = CGSize(width: 200, height: 300)
// Create PositionScrollViewModel
// (Need to create in parent view to bind the state between this view and PositionScrollView)
#ObservedObject var psViewModel = PositionScrollViewModel(
pageSize: CGSize(width: 200, height: 300),
horizontalScroll: Scroll(
scrollSetting: ScrollSetting(pageCount: 5, afterMoveType: .stickNearestUnitEdge),
pageLength: 200 // Page length of direction
)
)
public var body: some View {
return VStack {
PositionScrollView(
viewModel: self.psViewModel,
delegate: self
) {
HStack(spacing: 0) {
ForEach(0...4, id: \.self){ i in
ZStack {
Rectangle()
.fill(BLUES[i])
.border(Color.black)
.frame(
width: self.pageSize.width, height: self.pageSize.height
)
Text("Page\(i)")
.foregroundColor(Color.white)
.font(.system(size: 24, weight: .heavy, design: .default))
}
}
}
}
// Get page via scroll object
Text("page: \(self.psViewModel.horizontalScroll?.page ?? 0)")
// Get position via scroll object
Text("position: \(self.psViewModel.horizontalScroll?.position ?? 0)")
}
}
struct SampleView_Previews: PreviewProvider {
static var previews: some View {
return MinimalHorizontalExample()
}
}
// Delegate methods of PositionScrollView
// You can monitor changes of position
public func onScrollStart() {
print("onScrollStart")
}
public func onChangePage(page: Int) {
print("onChangePage to page: \(page)")
}
public func onChangePosition(position: CGFloat) {
print("position: \(position)")
}
public func onScrollEnd() {
print("onScrollEnd")
}
}