SwiftUI transmit drag focus to outer view (ScrollView inside dragable) - ios

What I want
I have a "ScrollView" in a custom sheet that I can drag down to close. Of course, the ScrollView is above the drag area, so when I drag over the ScrollView it scrolls down or up. I want to disable the ScrollView when I am at the top of the ScrollView and scroll up so that the sheet is dragged and starts to close. This is similar to the behaviour of the Shazam sheet.
The Problem
If the ScrollView is deactivated, the current drag action is not applied to the sheet, but does nothing. Only when dragging again (on the now deactivated ScrollView) the sheet is focused on the dragging. So is there a way to transfer the focus of the drag action from the ScrollView to the outer sheet view without starting a new one?
Showcase
I created a simplified version of the problem for better understanding.
We have a container (inner) that is draggable along the y-axis. On drag release, we let it jump back to offset 0.
#State var offsetY: CGFloat = .zero
var body: some View {
Inner(outerOffset: $offsetY)
.offset(y: offsetY)
.gesture(DragGesture().onChanged { value in
offsetY = value.translation.height
}.onEnded { _ in
offsetY = 0
}
)
}
Inside the container we have our own ScrollView (ScrollViewOffset) which takes a callback with the current scroll offset. If the offset is positive (i.e. if we are scrolling upwards, even if we are at the top of the content), we want to disable the scroll and let the outer container drag along the y-axis instead of the ScrollView. To activate the ScrollView we listen for the outerOffset (the drag value) and activate the ScrollView when the offset is 0 again (this happens when releasing the drag as described before).
struct Inner: View {
#Binding var outerOffset: CGFloat
#State var disableScroll = false
var body: some View {
ScrollViewReader { proxy in
ScrollViewOffset {
ForEach(0 ... 10, id: \.self) { e in
Text(String(e))
.id(e)
.padding()
Divider()
}
} onOffsetChange: { offset in
if offset > 0 {
disableScroll = true
}
}
.background(.red)
.padding()
.frame(width: nil, height: 400)
.onChange(of: outerOffset) { _ in
if outerOffset == 0 {
proxy.scrollTo(0)
disableScroll = false
}
}
.disabled(disableScroll)
}
}
}
When we run this, it is easy to see the problem I mentioned before. The ScrollView is getting disabled as it should, but the container is not focused on the drag. Only after starting a new drag, the container is focused.
My actual case
Last but not least the shazam equivalent (left) and my project (right).

Related

SwiftUI - How to calculate offset for HStack that centers subview on screen?

I have an HStack that has a variable number of subviews. When one of the subviews is tapped, I would like to center that subview on the screen. I want to use .offset() to simply move the HStack the correct amount in the proper direction such that the tapped subview becomes centered on the screen. I have the subviews organized in an array that I'm running a ForEach() on, and I'm keeping track of the selected subview by setting selectedButtonIndex to the index of the array of the tapped subview.
#State var buttons: [String] = ["Button"]
#State var selectedButtonIndex: Int = 0
HStack {
ForEach(0..<buttons.count) { index in
Rectangle()
.frame(width: 100, height: 50)
.onTapGesture {
selectedButtonIndex = index
}
.scaleEffect(selectedIndex == index ? 1.15 : 1) // the selected subview appears slightly bigger than the others
}
.offset(getOffset()) // I need to calculate this such that the selected subview is centered on the screen
and I have some button that adds an additional rectangle to the HStack such as:
Button("tap") {
buttons.append("another button")
}
How can I calculate the offset needed on the HStack to center the selected subview? (what can I put in the function getOffset() such that the selected subview gets centered?)
If I need to clarify any additional information, please leave a comment. Thank you in advance!
Simply set the offset to: the width of the button + the padding on each side * (the number of buttons / 2 - selectedButtonIndex).
In code:
110 * CGFloat(buttons.count / 2 - selectedButtonIndex)

ScrollView touch works despite all other touch is disabled

So my goal is to disable touch events for everything on screen and I don't want to use .disable on every view or to use Color.black.opacity(0.00001) because of the code smell. I want it to be a block that isn't visible for the user like if I would overlay Color.clear over the whole view. And I want it to behave like if I were to use Color.black.opacity(0.1) with it disabling touch events on every view underneath.
If I for example use a ZStack with:
Color.black.opacity(0.2) every view underneath will no longer register touch events. (I want this, but it should be transparent)
Color.black.opacity(0) every view underneath will register touch events.
Color.black.opacity(0).contentShape(Rectangle()), some events will register, some won't, for example buttons won't work though scrolling in a ScrollView, or using a toggle will still work.
Here is some example code
struct ContentView: View {
#State var numberOfRows: Int = 10
var body: some View {
ZStack {
Color.white
ScrollView {
VStack {
ForEach(0..<numberOfRows, id: \.self) { (call: Int) in
Text(String(call))
.frame(maxWidth: .infinity)
}
}
}
Button(action: {
numberOfRows += 1
}) {
Color.blue
.frame(width: 300, height: 300)
}
Color.black.opacity(0) // <- change this to 0.x to disable all touch
.contentShape(Rectangle()) // <- Remove this line to make blue button work (opacity needs to be 0)
}
}
}
Why is scrollview still receiving touch events and why is buttons not?
Is there a way to make my touch events for every view underneath, disabled?
Use instead (tested with Xcode 13.4 / iOS 15.5)
Color.clear
// .ignoresSafeArea(.all) // << if need all screen space
.contentShape(Rectangle())
.highPriorityGesture(DragGesture(minimumDistance: 0))

How to align and anchor SwiftUI horizontal list at right edge of view?

I am looking for how to align items in a list horizontally, where the items start at the last item, or on the right edge, and go left from there.
For example, with a list of 1-20, I'd be looking to show items 1-5 off the left edge of the screen, with item 20 being the right-most item, right on the edge of the screen. An image of this can be shown below (it is not the solution running, but rather a contrived image to show the desired effect).
How can I do this in SwiftUI?
An HStack will simply center itself horizontally. HStack doesn't seem to have a horizontal alignment that I can control.
An HStack in a scrollview allows me to reach the right-most item, but still starts at the first item, or the left-most edge of the list.
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Divider()
// the HStack just tries to fit everything within the width of the view,
// without anything going "offscreen"
HStack(spacing: 10) {
ForEach(1..<21) { index in
Text("\(index)")
}
Spacer()
}
Divider()
// placing the HStack within a ScrollView does allow elements to go offscreen,
// but the list starts at 1 and goes offscreen at 16 on the right
// I am looking for how to "anchor" the list on the right edge instead,
// with elements 1 through 5 being displayed off the left edge of the screen,
// and the right-most element being 20, directly on the right edge of the screen
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(1..<21) { index in
Text("\(index)")
}
}
}
.frame(height: 100)
Divider()
// are there any other ways to achieve my desired effect?
}
}
}
If I properly understand question looks like you need ScrollViewReader:
// Define array for your label
var entries = Array(0...20)
ScrollView(.horizontal) {
ScrollViewReader { reader in
HStack(spacing: 10) {
ForEach(entries, id: \.self) { index in
Text("\(index)")
}.onAppear {
// Here on appear scroll go to last element
reader.scrollTo(entries.count - 1)
}
}
}
}

SwiftUI ScrollView scrolls too far when using scrollViewReader

I'm creating an app where I'm presenting data in paged views and each view contains a vertical scrollable list, enabled by scrollView. I'm also using scrollViewReader to be able to programmatically scroll to a specific position in the list. This works OK in most cases, but when I programmatically scroll to the bottom of the list (to the last item), it scrolls too far.
I have isolated a case where this is repeatable:
ScrollView(.vertical, showsIndicators: false) {
ScrollViewReader { scrollView in
LazyVStack(alignment: .leading) {
ForEach((1..<100) , id:\.self) { key in
Text("Hello \(key)")
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
scrollView.scrollTo(99, anchor: .center)
}
}
}
}
}
If you run this on e.g. the iPhone 12 simulator, you will se that the last item in the list ends up in the middle of the screen. If you manually scroll and release, the list will "fall down" to the bottom of the screen, which is the position I want the list to have even though I scroll to the last item with .anchor: .center.
Is there some way around this behavior?
Edit: Realized I wasn't clear enough. I want the anchor to be .center as long as I have more scrollable content in my view. But when I get to the bottom of the list I want the scrollView to be smart enough to know that it's not possible to scroll any longer. As it is now, it scrolls more than is possible if you scroll manually.

Animate removing/adding SwiftUI view while animating offset

I'm trying to animate the offset of a SwiftUI view, while at the same time fading out and removing a subview of that view. The problem I'm running into is that SwiftUI performs the offset and fade-out animations, but doesn't combine them.
What I want to achieve to animate the position of the whole SubView, while simultaneously fading out the subtitle text, so that the subtitle text moves vertically while fading in or out. I can achieve this by animating the opacity of the Text instead of removing it, but that means the text will still take up "layout space".
Is there a way to achieve this animation with the if showSubtitle statement?
The following code and GIF demonstrate the problem:
struct ContentView: View {
#State private var showSubtitle = true
var body: some View {
SubView(showSubtitle: showSubtitle)
.animation(.default)
.offset(y: showSubtitle ? 100 : 0)
.onTapGesture {
self.showSubtitle.toggle()
}
}
}
struct SubView: View {
let showSubtitle: Bool
var body: some View {
VStack {
Text("Header")
if showSubtitle {
Text("Subtitle")
}
}
}
}
Actually the observed behaviour is because .offset does not change layout, the view is stand at the same place. So when you remove subview it is removed in-place and animating that removal (with default .opacity transition). The part that starts offsetting does not contain already subview, so you don't see it in moving up part.
Here is something that might give some kind of effect you expect, but transitions are based on source size, so it is not so far and manually specified distance of offset. Anyway, try:
if showSubtitle {
Text("Subtitle")
.transition(AnyTransition.opacity.combined(with: AnyTransition.move(edge: .top)))
}
Tested with Xcode 12 / iOS 14

Resources