SwiftUI: Trigger event after scrolling to end of a paged tabview - swift

I'm trying to have a prompt be called after a user reaches the end of a paged TabView and tries to scroll one extra. (For example, if there are three pages, once the user scrolls to the third page and tries to scroll to a fourth, it pulls up the prompt). I tried doing this:
TabView (selection: $currentIndex) {
...
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentIndex) { _ in
}
However, since the user is already on the last tab, the index isn't updated so the prompt can't be called. How can I achieve this?

Doesn't this work for you?. It reacts after showing 1/2 of the last (dummy) view:
struct ContentView: View {
#State private var currentIndex = 0
#State private var showAlert = false
let colors: [Color] = [.blue, .green, .cyan, .teal, .clear]
var body: some View {
TabView (selection: $currentIndex) {
ForEach(0..<5) { i in
ZStack {
colors[i]
Text("Tab \(currentIndex)")
.font(.title)
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentIndex) { _ in
if currentIndex == 4 {
currentIndex = 3
showAlert = true
}
}
.alert("You reached the end", isPresented: $showAlert) { }
}
}

Related

swift skips first variable update

I've been doing the Standford CS193p free course online and I have a strange situation with assignment 6. I'm supposed to create a theme list from which the user can directly start a game with the chosen theme. This list can be edited and when in editMode a tap gesture is used to open a sheet from which the tapped theme is edited. For this the tap gesture takes the index of the tapped theme and stores it as chosenThemeIndex.
I don't understand why this doesn't work the first time after running the code, meaning tapping on any list item opens always index 0 the first time, regardless of tapping on an item with another index. Then when closing the editing sheet and tapping on any other list item the correct theme is opened for editing. This means for me that Swift is skipping the first update from 0 to another index on chosenThemeIndex. Why is this happening and how can I correct this?
The complete app code can be pulled from branch Assignment6 on: https://github.com/kranca/Memorize.git
import SwiftUI
struct ThemeChooser: View {
#EnvironmentObject var store: ThemeStore
#State private var editMode: EditMode = .inactive
#State private var editing = false
var body: some View {
NavigationView {
List {
ForEach(store.themes) { theme in
let game = store.themes[theme].emojis.count > 1 ? EmojiMemoryGame(theme: theme) : nil
NavigationLink(destination: store.themes[theme].emojis.count > 1 ? EmojiMemoryGameView(game: game!) : nil, label: {
VStack(alignment: .leading) {
HStack {
VStack(alignment: .leading) {
Text(theme.name)
Text("Pairs: \(theme.cardPairs)")
}
RoundedRectangle(cornerRadius: 5)
.size(width: 30, height: 45)
.fill()
.foregroundColor(Color(rgbaColor: theme.rgbaColor))
}
Text(theme.emojis)
}
// call on gesture active only when in editMode
.gesture(editMode == .active ? tap(on: store.themes.firstIndex(of: theme) ?? 0) : nil)
})
}
.onDelete(perform: { indexSet in
store.themes.remove(atOffsets: indexSet)
})
.onMove(perform: { indexSet, newOffset in
store.themes.move(fromOffsets: indexSet, toOffset: newOffset)
})
}
.navigationTitle("Choose a theme!")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) { editMode == .active ? newThemeButton : nil }
ToolbarItem { EditButton() }
}
.sheet(isPresented: $editing) {
ThemeEditor(theme: $store.themes[chosenThemeIndex])
}
.environment(\.editMode, $editMode)
}
}
// variable I want to update
#State private var chosenThemeIndex: Int = 0
// gesture which takes tapped index and updates chosenThemeIndex
func tap(on tapedThemeIndex: Int) -> some Gesture {
TapGesture().onEnded {
chosenThemeIndex = tapedThemeIndex
editing = true
}
}
private var newThemeButton: some View {
Button("Add New Theme") {
chosenThemeIndex = 0
store.insertTheme(named: "", cardPairs: 2)
editing = true
}
}
}

TabView with .tabViewStyle(.page) does not dynamically update [duplicate]

I came across a weird Issue in SwiftUI.
I created a simple View that only holds a Button
and a TabView that uses the PageViewStyle. It seems that the TabView does not update it's content
correctly depending on the State of the Variable.
It seems that the content gets updated somehow but the View wont be updated how I would expect
Here is the Code of my View:
struct ContentView: View {
#State var numberOfPages: Int = 0
#State var selectedIndex = 0
var body: some View {
VStack {
Text("Tap Me").onTapGesture(count: 1, perform: {
self.numberOfPages = [2,5,10,15].randomElement()!
self.selectedIndex = 0
})
TabView(selection: $selectedIndex){
ForEach(0..<numberOfPages, id: \.self) { index in
Text("\(index)").background(Color.red)
}
}
.frame(height: 300)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
}.background(Color.blue)
}
}
This is how the result looks after tapping the label several Times.
The Initial State is no 0 Pages. After you tap i would expect that the content of the
TabView changes so all Pages will be scrollable and visible but just the page indicator updates it State for some reason.
TabView expects to have container of pages, but you included only one HStack (with own dynamic content), moreover chaining number of pages you have to reset tab view, so here is a fix.
Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State var numberOfPages: Int = 0
var body: some View {
VStack {
Text("Tap Me").onTapGesture(count: 1, perform: {
self.numberOfPages = [2,5,10,15].randomElement()!
})
if self.numberOfPages != 0 {
TabView {
ForEach(0..<numberOfPages, id: \.self) { index in
Text("\(index)").frame(width: 300).background(Color.red)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
.frame(height: 300)
.id(numberOfPages) // << here !!
}
}
}
}

SwiftUI - detecting Long Press while keeping TabView swipable

I'm trying to detect a Long Press gesture on TabView that's swipable.
The issue is that it disables TabView's swipable behavior at the moment.
Applying the gesture on individual VStacks didn't work either - the long press doesn't get detected if I tap on the background.
Here's a simplified version of my code - it can be copy-pasted into Swift Playground:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var currentSlideIndex: Int = 0
#GestureState var isPaused: Bool = false
var body: some View {
let tap = LongPressGesture(minimumDuration: 0.5,
maximumDistance: 10)
.updating($isPaused) { value, state, transaction in
state = value
}
Text(isPaused ? "Paused" : "Not Paused")
TabView(selection: $currentSlideIndex) {
VStack {
Text("Slide 1")
Button(action: { print("Slide 1 Button Tapped")}, label: {
Text("Button 1")
})
}
VStack {
Text("Slide 2")
Button(action: { print("Slide 2 Button Tapped")}, label: {
Text("Button 2")
})
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(width: 400, height: 700, alignment: .bottom)
.simultaneousGesture(tap)
.onChange(of: isPaused, perform: { value in
print("isPaused: \(isPaused)")
})
}
}
PlaygroundPage.current.setLiveView(ContentView())
The overall idea is that this TabView will be rotating the slides automatically but holding a finger on any of them should pause the rotation (similar to Instagram stories). I removed that logic for simplicity.
Update: using DragGesture didn't work either.
The issue here is with the precedence of Animations in SwiftUI. Because TabView is a struct we are unable to change, its animation detection precedence cannot really be changed. The solution to this, however clunky, is to write our own custom tab view that has the expected behavior.
I apologize for how much code is here, but the behavior you described is surprisingly complex. In essence, we have a TimeLineView that is sending automatic updates to our view, telling it to change the pages, as you would see on Instagram. TimeLineView is a new feature, so if you want this to work old school, you could replace it with a Timer and its onReceive method, but I'm using this for brevity.
In the pages themselves, we are listening for this update, but only actually changing the page to the next one if there is room to do so and we are not long pressing the view. We use the .updating modifier on the LongPressGesture to know exactly when our finger is still on the screen or not. This LongPressGesture is combined in a SimultaneousGesture with a DragGesture, so that the drag can also be activated. In the drag gesture, we wait for the user's mouse/finger to traverse a certain percentage of the screen before animating the change in pages.
When sliding backwards, we initiate an async request to set the animation direction back to sliding forwards once the animation completes, so that updates received from the TimeLineView still animate in the correct direction, no matter which way we just swiped. Using custom gestures here has the added benefit that if you choose to do so, you can implement some fancy geometry effects to more closely emulate Instagram's animations. At the same time, our CustomPageView is still fully interactable, which means I can still click on button1 and see it's onTapGesture print message!
One caveat of passing in Views to a struct as a generic as I am doing in CustomTabView is that all of the views must be of the same type, which is part of the reason the pages are now reusable structs in their own right. If you have any questions about what you can / can't do with this methodology, let me know, but I've just run this in Playground same as you and it works exactly as described.
import SwiftUI
import PlaygroundSupport
// Custom Tab View to handle all the expected behaviors
struct CustomTabView<Page: View>: View {
#Binding var pageIndex: Int
var pages: [Page]
/// Primary initializer for a Custom Tab View
/// - Parameters:
/// - pageIndex: The index controlling which page we are viewing
/// - pages: The views to display on each Page
init(_ pageIndex: Binding<Int>, pages: [() -> Page]) {
self._pageIndex = pageIndex
self.pages = pages.map { $0() }
}
struct currentPage<Page: View>: View {
#Binding var pageIndex: Int
#GestureState private var isPressingDown: Bool = false
#State private var forwards: Bool = true
private let animationDuration = 0.5
var pages: [Page]
var date: Date
/// - Parameters:
/// - pageIndex: The index controlling which page we are viewing
/// - pages: The views to display on each Page
/// - date: The current date
init(_ pageIndex: Binding<Int>, pages: [Page], date: Date) {
self._pageIndex = pageIndex
self.pages = pages
self.date = date
}
var body: some View {
// Ensure that the Page fills the screen
GeometryReader { bounds in
ZStack {
// You can obviously change this to whatever you like, but it's here right now because SwiftUI will not look for gestures on a clear background, and the CustomPageView I implemented is extremely bare
Color.red
// Space the Page horizontally to keep it centered
HStack {
Spacer()
pages[pageIndex]
Spacer()
}
}
// Frame this ZStack with the GeometryReader's bounds to include the full width in gesturable bounds
.frame(width: bounds.size.width, height: bounds.size.height)
// Identify this page by its index so SwiftUI knows our views are not identical
.id("page\(pageIndex)")
// Specify the transition type
.transition(getTransition())
.gesture(
// Either of these Gestures are allowed
SimultaneousGesture(
// Case 1, we perform a Long Press
LongPressGesture(minimumDuration: 0.1, maximumDistance: .infinity)
// Sequence this Gesture before an infinitely long press that will never trigger
.sequenced(before: LongPressGesture(minimumDuration: .infinity))
// Update the isPressingDown value
.updating($isPressingDown) { value, state, _ in
switch value {
// This means the first Gesture completed
case .second(true, nil):
// Update the GestureState
state = true
// We don't need to handle any other case
default: break
}
},
// Case 2, we perform a Drag Gesture
DragGesture(minimumDistance: 10)
.onChanged { onDragChange($0, bounds.size) }
)
)
}
// If the user releases their finger, set the slide animation direction back to forwards
.onChange(of: isPressingDown) { newValue in
if !newValue { forwards = true }
}
// When we receive a signal from the TimeLineView
.onChange(of: date) { _ in
// If the animation is not pause and there are still pages left to show
if !isPressingDown && pageIndex < pages.count - 1{
// This should always say sliding forwards, because this will only be triggered automatically
print("changing pages by sliding \(forwards ? "forwards" : "backwards")")
// Animate the change in pages
withAnimation(.easeIn(duration: animationDuration)) {
pageIndex += 1
}
}
}
}
/// Called when the Drag Gesture occurs
private func onDragChange(_ drag: DragGesture.Value, _ frame: CGSize) {
// If we've dragged across at least 15% of the screen, change the Page Index
if abs(drag.translation.width) / frame.width > 0.15 {
// If we're moving forwards and there is room
if drag.translation.width < 0 && pageIndex < pages.count - 1 {
forwards = true
withAnimation(.easeInOut(duration: animationDuration)) {
pageIndex += 1
}
}
// If we're moving backwards and there is room
else if drag.translation.width > 0 && pageIndex > 0 {
forwards = false
withAnimation(.easeInOut(duration: animationDuration)) {
pageIndex -= 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
forwards = true
}
}
}
}
// Tell the view which direction to slide
private func getTransition() -> AnyTransition {
// If we are swiping left / moving forwards
if forwards {
return .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
}
// If we are swiping right / moving backwards
else {
return .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
}
}
}
var body: some View {
ZStack {
// Create a TimeLine that updates every five seconds automatically
TimelineView(.periodic(from: Date(), by: 5)) { timeLine in
// Create a current page struct, as we cant react to timeLine.date changes in this view
currentPage($pageIndex, pages: pages, date: timeLine.date)
}
}
}
}
// This is the view that becomes the Page in our Custom Tab View, you can make it whatever you want as long as it is reusable
struct CustomPageView: View {
var title: String
var buttonTitle: String
var buttonAction: () -> ()
var body: some View {
VStack {
Text("\(title)")
Button(action: { buttonAction() }, label: { Text("\(buttonTitle)") })
}
}
}
struct ContentView: View {
#State var currentSlideIndex: Int = 0
#GestureState var isPaused: Bool = false
var body: some View {
CustomTabView($currentSlideIndex, pages: [
{
CustomPageView(title: "slide 1", buttonTitle: "button 1", buttonAction: { print("slide 1 button tapped") })
},
{
CustomPageView(title: "slide 2", buttonTitle: "button 2", buttonAction: { print("slide 2 button tapped") })
}]
)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(width: 400, height: 700, alignment: .bottom)
}
}
PlaygroundPage.current.setLiveView(ContentView())
I found the best and cleanest solution to this is just to add a clear view on top of your tabView when the slide show is active and put the gesture recognizer on that.
I haven't shown the implementation of the start stop timer which depends on your design.
private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#State var slideshowPlaying = false
#State var selection = 0
var body: some View {
ZStack {
TabView(selection: $selection) {
ForEach(modelArray.indices, id: \.self) { index in
SomeView()
.tag(index)
}
}
.tabViewStyle(PageTabViewStyle())
.background(Color(.systemGroupedBackground))
.onReceive(self.timer) { _ in
if selection < modelArray.count + 1 {
selection += 1
} else {
selection = 0
}
}
if slideshowPlaying {
Color.clear
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { _ in
slideshowPlaying = false
})
}
}
}

Can you animate a SwiftUI View on disappear?

I have a SwiftUI View which has a custom animation that runs onAppear. I am trying to get the view to animate onDisappear too but it just immediately vanishes.
The below example reproduces the problem - the MyText view should slide in from the left and slide out to the right. The id modifier is used to ensure a new view is rendered each time the value changes, and I have confirmed that both onAppear and onDisappear are indeed called each time, but the animation onDisappear never visibly runs. How can I achieve this?
struct Survey: View {
#State private var id = 0
var body: some View {
VStack {
MyText(text: "\(id)").id(id)
Button("Increment") {
self.id += 1
}
}
}
struct MyText: View {
#State private var offset: CGFloat = -100
let text: String
var body: some View {
return Text(text)
.offset(x: offset)
.onAppear() {
withAnimation(.easeInOut(duration: 2)) {
self.offset = 0
}
}
.onDisappear() {
withAnimation(.easeInOut(duration: 2)) {
self.offset = 100
}
}
}
}
}
Probably you wanted transition, something like
Update: re-tested with Xcode 13.4 / iOS 15.5
struct Survey: View {
#State private var id = 0
var body: some View {
VStack {
MyText(text: "\(id)")
Button("Increment") {
self.id += 1
}
}
}
struct MyText: View {
var text: String
var body: some View {
Text("\(text)").id(text)
.frame(maxWidth: .infinity)
.transition(.slide)
.animation(.easeInOut(duration: 2), value: text)
}
}
}
I'm afraid it can't work since the .onDisappear modifier is called once the view is hidden.
However there is a nice answer here :
Is there a SwiftUI equivalent for viewWillDisappear(_:) or detect when a view is about to be removed?

SwiftUI: Change a view's transition dynamically after the view is created

I want to change a view's transition dynamically after the view is created. I toggle a State variable isTransition1 by clicking a button to switch between transition1 and transition2 as the below. However, it doesn't work as intended if one of these transitions is opacity. The view to be removed immediately after changing transition always keeps its original transition. Surprisingly, if I change transition2 to slide, it will work without problem. The view to be removed will use the new transition. Is there any way to make opacity work here?
let transition1 = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
let transition2 = AnyTransition.opacity
struct Wrapper1<Content: View>: View {
let content: Content
var body: some View {
content
}
}
struct Wrapper2<Content: View>: View {
let content: Content
var body: some View {
content
}
}
struct TextView: View {
let count: Int
let color: Color
var body: some View {
ZStack {
color
.edgesIgnoringSafeArea(.all)
.frame(maxWidth: UIScreen.main.bounds.width,
maxHeight: UIScreen.main.bounds.height)
Text("Count: \(count)")
.font(.title)
.offset(y: -200)
}
}
}
struct ContentView: View {
#State private var count = 0
#State private var isTransition1 = false
var body: some View {
ZStack {
if count % 2 == 0 {
Wrapper1(content: TextView(count: count, color: Color.green)
.transition(isTransition1 ? transition1 : transition2))
} else {
Wrapper2(content: TextView(count: count, color: Color.red)
.transition(isTransition1 ? transition1 : transition2))
}
HStack(spacing: 100) {
Button(action: {
self.isTransition1.toggle()
}) {
Text("Toggle Transition").font(.title)
}
Button(action: {
withAnimation(.linear(duration: 2)) {
self.count += 1
}
}) {
Text("Increase").font(.title)
}
}
}
}
}
Not sure if I correctly understood what effect you tried to achieve, but try to reset view hierarchy (at least this definitely resets transitions, so they don't affect each other):
var body: some View {
ZStack {
if count % 2 == 0 {
Wrapper1(content: TextView(count: count, color: Color.green)
.transition(isTransition1 ? transition1 : transition2))
} else {
Wrapper2(content: TextView(count: count, color: Color.red)
.transition(isTransition1 ? transition1 : transition2))
}
HStack(spacing: 100) {
Button(action: {
self.isTransition1.toggle()
}) {
Text("Toggle Transition").font(.title)
}
Button(action: {
withAnimation(.linear(duration: 2)) {
self.count += 1
}
}) {
Text("Increase").font(.title)
}
}
}.id(isTransition1) // << here !!
}
I had the same issue recently where I wanted to change the transition inbetween states. Nothing seemed to work until I decided to create an intermediate internal state that updates the UI only after it has updated the transition. I am using a view model that is an observable object.
To solve the issue I created an internal state that is a currentValueSubject.
I also made my transition a published Variable so as to update the UI after the transition changes.
I update the internal state, which in turn updates the transition, which then updates the UI before changing the state.
private var internalState: CurrentValueSubject<BookingWizardState, Never> = CurrentValueSubject(.date)
#Published var state: BookingWizardState
private let moveForwardTransition = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
private let moveBackwardTransition = AnyTransition.asymmetric(insertion: .move(edge: .leading),
removal: .move(edge: .trailing))
#Published var transition: AnyTransition
func setupSubscriptions() {
//Set the transition based on the state change
internalState.map { [weak self] newState in
guard let self else { return .slide }
let isForward = self.state.rawValue <= newState.rawValue
return isForward ? self.moveForwardTransition : self.moveBackwardTransition
}
.assign(to: &$transition)
//Update the external state after a fraction of a second and after the transition has been updated.
internalState
.delay(for: .seconds(0.1), scheduler: RunLoop.main)
.assign(to: &$state)
}