How can I read Drag location in ScrollView in SwiftUI? - swiftui

I am trying to read location in DragGesture of a View connected to a ScrollView, but ScrollView take control of DragGesture on all view and does not let me to read those information.
Here is my example to show the issue,
My Goal is reading the Drag location in DragGesture.
using new code:
struct ContentView: View {
#State var locationY: CGFloat = .zero
var body: some View {
VStack
{
HStack
{
VStack
{
Color.black.frame(height: 300)
}
.gesture( DragGesture().onChanged { value in locationY = value.location.y; print(locationY)} )
ScrollView
{
VStack
{
Color.blue.frame(height: 300)
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: $0.frame(in: .global).origin.y)
// value: $0.frame(in: .named("scroll_area")).origin.y)
})
}
// .coordinateSpace(name: "scroll_area")
.onPreferenceChange(ViewOffsetKey.self) {
self.locationY = $0
print(">> offset: \($0)")
}
.frame(height: 300)
}
Spacer()
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}

Here is possible solution, to use GeometryReader in background to read rect of view (in any needed coordinate space) and view preferences to pass needed coordinate at top level.
Tested with Xcode 12.1 / iOS 14.1
Note: commented variant is for superview (ie. ScrollView) coordinates
ScrollView
{
VStack
{
Color.blue.frame(height: 300)
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: $0.frame(in: .global).origin.y)
// value: $0.frame(in: .named("scroll_area")).origin.y)
})
}
// .coordinateSpace(name: "scroll_area")
.onPreferenceChange(ViewOffsetKey.self) {
self.locationY = $0
print(">> offset: \($0)")
}
and helper preference key
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}

Related

Is this possible to return which griditem in LazyVGrid using position in SwiftUI?

I got a LazyVGrid, and I would like to know is this possible to return which griditem by given a gesture.location. Thanks.
To store the location of each item in a LazyVGrid, do this using PreferenceKey:
First define an object to store the frame for each view in the grid:
struct ModelFrame: Hashable {
let id: String
let frame: CGRect
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
then create a PreferenceKey as follows:
struct ModelFrameKey: PreferenceKey {
static var defaultValue: Set<ModelFrame> = []
static func reduce(value: inout Set<ModelFrame>, nextValue: () -> Set<ModelFrame>) {
value = value.union(nextValue())
}
}
Then for each view in the grid, add a clear background wrapped in a GeometryReader with .preference modifier to store the frame for the view.
struct Model: Identifiable {
var id: String { name }
let name: String
}
struct ContentView: View {
let models = ["abc", "def", "ghi", "jkl", "mno", "pqr", "stu"].map(Model.init)
#State private var modelFrames: Set<ModelFrame> = []
#State private var overModel: Model?
var body: some View {
VStack {
Text("Over: \(overModel?.name ?? "nothing")")
.font(.title)
LazyVGrid(columns: [GridItem(), GridItem()]) {
ForEach(models) { model in
Text(model.name)
.padding()
.background(overModel?.id == model.id ? .red : .yellow, in: RoundedRectangle(cornerRadius: 8))
.background {
GeometryReader { proxy in
Color.clear.preference(key: ModelFrameKey.self, value: [ModelFrame(id: model.id, frame: proxy.frame(in: .named("Grid")))])
}
}
}
}
.coordinateSpace(name: "Grid")
.onPreferenceChange(ModelFrameKey.self) { frames in
self.modelFrames = frames
}
.gesture(
DragGesture()
.onChanged { value in
overModel = model(containing: value.location)
}
)
}
}
func model(containing point: CGPoint) -> Model? {
guard let id = modelFrames.first(where: { $0.frame.contains(point) })?.id else {
return nil
}
return models.first(where: { $0.id == id })
}
Now you have your frames, you can use CGRect.contains(CGPoint) in your drag gesture to see which view contains the current location.

SwiftUI TextEditor does not adapt to keyboard Modifier like Textfield

I have a SwiftUI TextEditor , unlike Textfields that moves up when keyboard appears from this answer in this link Move TextField up when the keyboard has appeared in SwiftUI
the TextEditor does not respond to this keyboard modifiers and the keyboard hides a big chunk of the TextEditor view . Is there a bug with SwiftUI TextEditor . How can i do that ?
Are you looking for a dynamic textEditor like this? I set a maximum and minimum height so that it does not grow infinitely but you can easily get rid of that.
import SwiftUI
struct ReflectionSpace: View {
#State private var textEditorHeight : CGFloat = 100
#State private var reflection = ""
var body: some View {
ScrollView {
VStack{
ZStack(alignment: .leading) {
Text(reflection)
.font(.custom("Times", size: 12
))
.lineSpacing(1)
.frame(minHeight: 50)
.frame(maxHeight: 300)
.foregroundColor(.clear)
.padding()
.background(GeometryReader {
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
TextEditor(text: $reflection)
.font(.custom("Times", size: 12))
.padding(6)
.lineSpacing(1)
.background(Color.red)
}
.padding(20)
.onPreferenceChange(ViewHeightKey.self) { textEditorHeight = $0 }
}
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func
reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
struct ReflectionSpace_Previews: PreviewProvider {
static var previews: some View {
ReflectionSpace()
}
}

How to create synced ScrollViews in SwiftUI

I am trying to create two synced ScrollViews in SwiftUI such that scrolling in one will result in the same scrolling in the other.
I am using the ScrollViewOffset class shown at the bottom for getting a scrollView offset value but having trouble figuring out how to scroll the other view.
I seem to be able to 'hack' it by preventing scrolling in one view and setting the content position() on the other - is there any way to actually scroll the scrollView content to a position - I know ScrollViewReader seems to allow scrolling to display content items but I can't seem to find anything that will scroll the contents to an offset position.
The problem with using position() is that it does not actually change the ScrollViews scroller positions - there seems to be no ScrollView.scrollContentsTo(point: CGPoint).
#State private var scrollOffset1: CGPoint = .zero
HStack {
ScrollViewOffset(onOffsetChange: { offset in
scrollOffset1 = offset
print("New ScrollView1 offset: \(offset)")
}, content: {
VStack {
ImageView(filteredImageProvider: self.provider)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
})
ScrollView([]) {
VStack {
ImageView(filteredImageProvider: self.provider, showEdits: false)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
.position(x: scrollOffset1.x, y: scrollOffset1.y + (imageHeight + (geometry.size.height - 20) * 2)/2)
}
}
//
// ScrollViewOffset.swift
// ZoomView
//
//
import Foundation
import SwiftUI
struct ScrollViewOffset<Content: View>: View {
let onOffsetChange: (CGPoint) -> Void
let content: () -> Content
init(
onOffsetChange: #escaping (CGPoint) -> Void,
#ViewBuilder content: #escaping () -> Content
) {
self.onOffsetChange = onOffsetChange
self.content = content
}
var body: some View {
ScrollView([.horizontal, .vertical]) {
offsetReader
content()
.padding(.top, -8)
}
.coordinateSpace(name: "frameLayer")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: onOffsetChange)
}
var offsetReader: some View {
GeometryReader { proxy in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: proxy.frame(in: .named("frameLayer")).origin
)
}
.frame(width: 0, height: 0)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
Synced scroll views.
In this example, you can scroll the LHS scrollview and the RHS scrollview will be synchronised to the same position. In this example, the scrollview on the RHS is disabled, and the position is simply synchronised by using an offset.
But using the same logic and code, you can make both the LHS and RHS scrollviews synced when either of them are scrolled.
import SwiftUI
struct ContentView: View {
#State private var offset = CGFloat.zero
var body: some View {
HStack(alignment: .top) {
// MainScrollView
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.background( GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { value in
print("offset >> \(value)")
offset = value
}
}
.coordinateSpace(name: "scroll")
// Synchronised with ScrollView above
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.offset(y: -offset)
}
.disabled(true)
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
With the current API of ScrollView, this is not possible. While you can get the contentOffset of the scrollView using methods that are widely available on the internet, the ScrollViewReader that is used to programmatically scroll a ScrollView only allows you to scroll to specific views, instead of to a contentOffset.
To achieve this functionality, you are going to have to wrap UIScrollView. Here is an implementation, although it isn't 100% stable, and is missing a good amount of scrollView functionality:
import SwiftUI
import UIKit
public struct ScrollableView<Content: View>: UIViewControllerRepresentable {
#Binding var offset: CGPoint
var content: () -> Content
public init(_ offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self._offset = offset
self.content = content
}
public func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
vc.scrollView.setContentOffset(offset, animated: false)
vc.delegate = context.coordinator
return vc
}
public func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
// Allow for deaceleration to be done by the scrollView
if !viewController.scrollView.isDecelerating {
viewController.scrollView.setContentOffset(offset, animated: false)
}
}
public func makeCoordinator() -> Coordinator {
Coordinator(contentOffset: _offset)
}
public class Coordinator: NSObject, UIScrollViewDelegate {
let contentOffset: Binding<CGPoint>
init(contentOffset: Binding<CGPoint>) {
self.contentOffset = contentOffset
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentOffset.wrappedValue = scrollView.contentOffset
}
}
}
public class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = UIScrollView()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
weak var delegate: UIScrollViewDelegate?
public override func viewDidLoad() {
super.viewDidLoad()
self.scrollView.delegate = delegate
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}
struct ScrollableView_Previews: PreviewProvider {
static var previews: some View {
Wrapper()
}
struct Wrapper: View {
#State var offset: CGPoint = .init(x: 0, y: 50)
var body: some View {
HStack {
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
VStack {
Text("x: \(offset.x) y: \(offset.y)")
Button("Top", action: {
offset = .zero
})
.buttonStyle(.borderedProminent)
}
.frame(width: 200)
.padding()
}
}
}
}

GeometryReader inside ScrollView - scroll doesn't work anymore [duplicate]

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.
But I was wondering if it is also possible to get the current scroll position?
It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.
Thanks!
It was possible to read it and before. Here is a solution based on view preferences.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
}.coordinateSpace(name: "scroll")
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.
struct DemoScrollViewOffsetView: View {
#State private var offset = CGFloat.zero
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}.background(GeometryReader { proxy -> Color in
DispatchQueue.main.async {
offset = -proxy.frame(in: .named("scroll")).origin.y
}
return Color.clear
})
}.coordinateSpace(name: "scroll")
}
}
I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).
After a bit of "beautification" I ended up with this usage:
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
List(0..<100) { i in
Text("Item \(i)")
.onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
print("rect of item \(i): \(String(describing: frame)))")
}
}
.trackListFrame()
}
}
}
which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking
The most popular answer (#Asperi's) has a limitation:
The scroll offset can be used in a function
.onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
which is convenient for triggering an event based on that offset.
But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a #State.
The problem then is that each time this offset changes, the #State is updated and the body is re-evaluated. This causes a slow display.
We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
}
where content is (CGPoint) -> some View
We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin,
perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2,
scheduler: DispatchQueue.main),
perform: didEndScrolling)
}
where offsetObserver = PassthroughSubject<CGPoint, Never>()
In the end, this gives :
struct _ScrollViewWithOffset<Content: View>: View {
private let axis: Axis.Set
private let content: (CGPoint) -> Content
private let didEndScrolling: (CGPoint) -> Void
private let offsetObserver = PassthroughSubject<CGPoint, Never>()
private let spaceName = "scrollView"
init(axis: Axis.Set = .vertical,
content: #escaping (CGPoint) -> Content,
didEndScrolling: #escaping (CGPoint) -> Void = { _ in }) {
self.axis = axis
self.content = content
self.didEndScrolling = didEndScrolling
}
var body: some View {
ScrollView(axis) {
GeometryReader { geometry in
content(geometry.frame(in: .named(spaceName)).origin)
.onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
.onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.coordinateSpace(name: spaceName)
}
}
Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.
struct ScrollViewWithOffset<Content: View>: View {
#State private var height: CGFloat?
#State private var width: CGFloat?
let axis: Axis.Set
let content: (CGPoint) -> Content
let didEndScrolling: (CGPoint) -> Void
var body: some View {
_ScrollViewWithOffset(axis: axis) { offset in
content(offset)
.fixedSize()
.overlay(GeometryReader { geo in
Color.clear
.onAppear {
height = geo.size.height
width = geo.size.width
}
})
} didEndScrolling: {
didEndScrolling($0)
}
.frame(width: axis == .vertical ? width : nil,
height: axis == .horizontal ? height : nil)
}
}
This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :
struct ScrollViewWithOffsetForPreviews: View {
#State private var cpt = 0
let axis: Axis.Set
var body: some View {
NavigationView {
ScrollViewWithOffset(axis: axis) { offset in
VStack {
Color.pink
.frame(width: 100, height: 100)
Text(offset.x.description)
Text(offset.y.description)
Text(cpt.description)
}
} didEndScrolling: { _ in
cpt += 1
}
.background(Color.mint)
.navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
}
}
}

Measure the rendered size of a SwiftUI view?

Is there a way to measure the computed size of a view after SwiftUI runs its view rendering phase? For example, given the following view:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
}
}
With the view selected, the computed size is displayed In the preview canvas in the bottom left corner. Does anyone know of a way to get access to that size in code?
Printing out values is good, but being able to use them inside the parent view (or elsewhere) is better. So I took one more step to elaborate it.
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { (g) -> Path in
print("width: \(g.size.width), height: \(g.size.height)")
DispatchQueue.main.async { // avoids warning: 'Modifying state during view update.' Doesn't look very reliable, but works.
self.rect = g.frame(in: .global)
}
return Path() // could be some other dummy view
}
}
}
struct ContentView: View {
#State private var rect1: CGRect = CGRect()
var body: some View {
HStack {
// make to texts equal width, for example
// this is not a good way to achieve this, just for demo
Text("Long text").background(Color.blue).background(GeometryGetter(rect: $rect1))
// You can then use rect in other places of your view:
Text("text").frame(width: rect1.width, height: rect1.height).background(Color.green)
Text("text").background(Color.yellow)
}
}
}
You could add an "overlay" using a GeometryReader to see the values. But in practice it would probably be better to use a "background" modifier and handle the sizing value discretely
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(
GeometryReader { proxy in
Text("\(proxy.size.width) x \(proxy.size.height)")
}
)
}
}
Here is the ugly way I came up with to achieve this:
struct GeometryPrintingView: View {
var body: some View {
GeometryReader { geometry in
return self.makeViewAndPrint(geometry: geometry)
}
}
func makeViewAndPrint(geometry: GeometryProxy) -> Text {
print(geometry.size)
return Text("")
}
}
And updated Foo version:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(GeometryPrintingView())
}
}
To anyone who wants to obtain a size out of Jack's solution and store it in some property for further use:
.overlay(
GeometryReader { proxy in
Text(String())
.onAppear() {
// Property, eg
// #State private var viewSizeProperty = CGSize.zero
viewSizeProperty = proxy.size
}
.opacity(.zero)
}
)
This is a bit dirty obviously, but why not if it works.
Try to use PreferenceKey like this.
struct HeightPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct WidthPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct SizePreferenceKey : PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
func readWidth() -> some View {
background(GeometryReader {
Color.clear.preference(key: WidthPreferenceKey.self, value: $0.size.width)
})
}
func readHeight() -> some View {
background(GeometryReader {
Color.clear.preference(key: HeightPreferenceKey.self, value: $0.size.height)
})
}
func onWidthChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(WidthPreferenceKey.self) { width in
action(width)
}
}
func onHeightChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(HeightPreferenceKey.self) { height in
action(height)
}
}
func readSize() -> some View {
background(GeometryReader {
Color.clear.preference(key: SizePreferenceKey.self, value: $0.size)
})
}
func onSizeChange(perform action: #escaping (CGSize) -> Void) -> some View {
onPreferenceChange(SizePreferenceKey.self) { size in
action(size)
}
}
}
struct MyView: View {
#State private var height: CGFloat = 0
var body: some View {
...
.readHeight()
.onHeightChange {
height = $0
}
}
}
As others pointed out, GeometryReader and a custom PreferenceKey is the best way forward for now. I've implemented a helper drop-in library which does pretty much that: https://github.com/srgtuszy/measure-size-swiftui