In SwiftUI I have this sequence I'd like a specific way where I swipe up in frame 1 into frame 2, then swipe left into frame 3. This works fine, as intended.
I did this by changing the views so essentially there is two views and they both share frame 2. So I have frame 2a & 2b are essentially the same but live in different Views. That way I can transition from vertical scrollview to horizontal scrollview.
This works as-stated, but the transition is a bit jarring because it goes halfway to 2a in firstView() and abruptly switches to 2b in secondView().
I think what I want is for some way to check for ScrollTransitionPhase.isIdentity before setting the View.id ?
I'm new to Swift in general and this is my first few attempts at SwiftUI iOS.
struct firstView: View {
@Binding var scrollId: Int?
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 0) {
Rectangle()
.fill(.gray)
.overlay
{
Text("Frame 1 ")
.foregroundStyle(.white)
}
.padding(10)
.containerRelativeFrame([.horizontal, .vertical])
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
.id(1)
Rectangle()
.fill(.gray)
.overlay
{
Text("Frame 2a ")
.foregroundStyle(.white)
}
.padding(10)
.containerRelativeFrame([.horizontal, .vertical])
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
.id(2)
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.scrollPosition(id: $scrollId)
Text("Hello World!")
}
}
struct secondView: View {
@Binding var scrollId: Int?
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 0) {
Rectangle()
.fill(.gray)
.overlay
{
Text("Frame 2b ")
.foregroundStyle(.white)
}
.padding(10)
.containerRelativeFrame([.horizontal, .vertical])
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
.id(3)
Rectangle()
.fill(.gray)
.overlay
{
Text("Frame 3 ")
.foregroundStyle(.white)
}
.padding(10)
.containerRelativeFrame([.horizontal, .vertical])
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
.id(4)
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
Text("Do you want to play a game?")
}
}
struct lastView: View {
var body: some View {
Text("Now is the time for all good men")
}
}
struct TestSwiftUIView4: View {
@State var scrollId: Int?
var body: some View {
switch scrollId {
case 4:
lastView()
case 2:
secondView(scrollId: $scrollId)
default:
firstView(scrollId: $scrollId)
}
}
}
In firstView
, the modifier scrollPosition
has been used to bind the scroll position to the variable scrollId
. This means, when the ScrollView
in firstView
is scrolled up, the state variable scrollId
gets updated.
The state variable probably gets updated half-way through the transition. However, when this happens, the parent view TestSwiftUIView4
replaces firstView
with secondView
. So this is why an abrupt change is happening.
You can soften the transition a bit by adding an .animation
modifier to the parent view. This requires nesting the switch statement inside a Group
:
var body: some View {
Group {
switch scrollId {
case 4:
lastView()
case 2:
secondView(scrollId: $scrollId)
default:
firstView(scrollId: $scrollId)
}
}
.animation(.easeInOut, value: scrollId)
}
However, I would suggest, this whole view construction could be simplified:
ScrollView
as the parent container for the child views.scrollId
does not need to be passed to secondView
, because it is not being updated. Indeed, frames 2 and 3 no longer need specific ids, because the ids are never referred to.lastView
could ever be reached, I guess it was just a dummy view that was never intended to be shown?The only tricky bit is to disable scrolling on the vertical ScrollView
when Frame 1 is scrolled up. I began by trying this:
ScrollView(.vertical) {
// ...
}
.scrollDisabled(scrollId == 2)
This works, but it sometimes gives an abrupt transition like you had before, depending on how fast Frame 1 is scrolled up.
A better solution is to use a separate state variable to control the disabled scrolling. The state variable can be updated when it is detected that secondView
has been scrolled up to the top. A GeometryReader
in the background of secondView
can be used to determine this.
In fact, once you have a flag that is updated when secondView
is scrolled up, there is no need to track scrollId
at all. This means the .scrollPosition
can be dropped too, along with the other .id
modifiers.
Here is the fully revised version:
struct firstView: View {
var body: some View {
Color.gray
.overlay { Text("Frame 1") }
.padding(10)
.containerRelativeFrame([.horizontal, .vertical])
}
}
struct secondView: View {
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 0) {
Color.gray
.overlay { Text("Frame 2") }
.padding(10)
.containerRelativeFrame([.horizontal, .vertical])
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
Color.gray
.overlay { Text("Frame 3") }
.padding(10)
.containerRelativeFrame([.horizontal, .vertical])
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
}
}
struct TestSwiftUIView4: View {
@State private var isSecondViewShowing = false
private var scrollDetector: some View {
GeometryReader { proxy in
let minY = proxy.frame(in: .scrollView).minY
Color.clear
.onChange(of: minY) { oldVal, newVal in
if !isSecondViewShowing, newVal <= 0 {
isSecondViewShowing = true
}
}
}
}
var body: some View {
VStack {
ScrollView(.vertical) {
VStack(spacing: 0) {
firstView()
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
secondView()
.scrollTransition { content, phase in
content.opacity(phase.isIdentity ? 1: 0)
}
.background { scrollDetector }
}
.foregroundStyle(.white)
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.paging)
.scrollDisabled(isSecondViewShowing)
if isSecondViewShowing {
Text("Do you want to play a game?")
} else {
Text("Hello World!")
}
}
}
}