I have a swiftUI animation based on some state:
withAnimation(.linear(duration: 0.1)) {
self.someState = newState
}
Is there any callback which is triggered when the above animation completes?
If there are any suggestions on how to accomplish an animation with a completion block in SwiftUI which are not withAnimation, I'm open to those as well.
I would like to know when the animation completes so I can do something else, for the purpose of this example, I just want to print to console when the animation completes.
Unfortunately there's no good solution to this problem (yet).
However, if you can specify the duration of an Animation, you can use DispatchQueue.main.asyncAfter to trigger an action exactly when the animation finishes:
withAnimation(.linear(duration: 0.1)) {
self.someState = newState
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
print("Animation finished")
}
Here's a bit simplified and generalized version that could be used for any single value animations. This is based on some other examples I was able to find on the internet while waiting for Apple to provide a more convenient way:
struct AnimatableModifierDouble: AnimatableModifier {
var targetValue: Double
// SwiftUI gradually varies it from old value to the new value
var animatableData: Double {
didSet {
checkIfFinished()
}
}
var completion: () -> ()
// Re-created every time the control argument changes
init(bindedValue: Double, completion: #escaping () -> ()) {
self.completion = completion
// Set animatableData to the new value. But SwiftUI again directly
// and gradually varies the value while the body
// is being called to animate. Following line serves the purpose of
// associating the extenal argument with the animatableData.
self.animatableData = bindedValue
targetValue = bindedValue
}
func checkIfFinished() -> () {
//print("Current value: \(animatableData)")
if (animatableData == targetValue) {
//if animatableData.isEqual(to: targetValue) {
DispatchQueue.main.async {
self.completion()
}
}
}
// Called after each gradual change in animatableData to allow the
// modifier to animate
func body(content: Content) -> some View {
// content is the view on which .modifier is applied
content
// We don't want the system also to
// implicitly animate default system animatons it each time we set it. It will also cancel
// out other implicit animations now present on the content.
.animation(nil)
}
}
And here's an example on how to use it with text opacity animation:
import SwiftUI
struct ContentView: View {
// Need to create state property
#State var textOpacity: Double = 0.0
var body: some View {
VStack {
Text("Hello world!")
.font(.largeTitle)
// Pass generic animatable modifier for animating double values
.modifier(AnimatableModifierDouble(bindedValue: textOpacity) {
// Finished, hurray!
print("finished")
// Reset opacity so that you could tap the button and animate again
self.textOpacity = 0.0
}).opacity(textOpacity) // bind text opacity to your state property
Button(action: {
withAnimation(.easeInOut(duration: 1.0)) {
self.textOpacity = 1.0 // Change your state property and trigger animation to start
}
}) {
Text("Animate")
}
}
}
}
struct HomeView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
On this blog this Guy Javier describes how to use GeometryEffect in order to have animation feedback, in his example he detects when the animation is at 50% so he can flip the view and make it looks like the view has 2 sides
here is the link to the full article with a lot of explanations: https://swiftui-lab.com/swiftui-animations-part2/
I will copy the relevant snippets here so the answer can still be relevant even if the link is not valid no more:
In this example #Binding var flipped: Bool becomes true when the angle is between 90 and 270 and then false.
struct FlipEffect: GeometryEffect {
var animatableData: Double {
get { angle }
set { angle = newValue }
}
#Binding var flipped: Bool
var angle: Double
let axis: (x: CGFloat, y: CGFloat)
func effectValue(size: CGSize) -> ProjectionTransform {
// We schedule the change to be done after the view has finished drawing,
// otherwise, we would receive a runtime error, indicating we are changing
// the state while the view is being drawn.
DispatchQueue.main.async {
self.flipped = self.angle >= 90 && self.angle < 270
}
let a = CGFloat(Angle(degrees: angle).radians)
var transform3d = CATransform3DIdentity;
transform3d.m34 = -1/max(size.width, size.height)
transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0)
transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
return ProjectionTransform(transform3d).concatenating(affineTransform)
}
}
You should be able to change the animation to whatever you want to achieve and then get the binding to change the state of the parent once it is done.
You need to use a custom modifier.
I have done an example to animate the offset in the X-axis with a completion block.
struct OffsetXEffectModifier: AnimatableModifier {
var initialOffsetX: CGFloat
var offsetX: CGFloat
var onCompletion: (() -> Void)?
init(offsetX: CGFloat, onCompletion: (() -> Void)? = nil) {
self.initialOffsetX = offsetX
self.offsetX = offsetX
self.onCompletion = onCompletion
}
var animatableData: CGFloat {
get { offsetX }
set {
offsetX = newValue
checkIfFinished()
}
}
func checkIfFinished() -> () {
if let onCompletion = onCompletion, offsetX == initialOffsetX {
DispatchQueue.main.async {
onCompletion()
}
}
}
func body(content: Content) -> some View {
content.offset(x: offsetX)
}
}
struct OffsetXEffectModifier_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Text("Hello")
.modifier(
OffsetXEffectModifier(offsetX: 10, onCompletion: {
print("Completed")
})
)
}
.frame(width: 100, height: 100, alignment: .bottomLeading)
.previewLayout(.sizeThatFits)
}
}
You can try VDAnimation library
Animate(animationStore) {
self.someState =~ newState
}
.duration(0.1)
.curve(.linear)
.start {
...
}
Related
I've been looking to add a loading indicator to my project, and found a really cool animation here. To make it easier to use, I wanted to incorporate it into a view modifier to put it on top of the current view. However, when I do so, it doesn't animate when I first press the button. I have played around with it a little, and my hypothesis is that the View Modifier doesn't pass the initial isAnimating = false, so only passes it isAnimating = true when the button is pressed. Because the ArcsAnimationView doesn't get the false value initially, it doesn't actually animate anything and just shows the static arcs. However, if I press the button a second time afterwards, it seems to be initialized and the view properly animates as desired.
Is there a better way to structure my code to avoid this issue? Am I missing something key? Any help is greatly appreciated.
Below is the complete code:
import SwiftUI
struct ArcsAnimationView: View {
#Binding var isAnimating: Bool
let count: UInt = 4
let width: CGFloat = 5
let spacing: CGFloat = 2
init(isAnimating: Binding<Bool>) {
self._isAnimating = isAnimating
}
var body: some View {
GeometryReader { geometry in
ForEach(0..<Int(count)) { index in
item(forIndex: index, in: geometry.size)
// the rotation below is what is animated ...
// I think the problem is that it just starts at .degrees(360), instead of
// .degrees(0) as expected, where it is then animated to .degrees(360)
.rotationEffect(isAnimating ? .degrees(360) : .degrees(0))
.animation(
Animation.default
.speed(Double.random(in: 0.05...0.25))
.repeatCount(isAnimating ? .max : 1, autoreverses: false)
, value: isAnimating
)
.foregroundColor(Color(hex: AppColors.darkBlue1.rawValue))
}
}
.aspectRatio(contentMode: .fit)
}
private func item(forIndex index: Int, in geometrySize: CGSize) -> some View {
Group { () -> Path in
var p = Path()
p.addArc(center: CGPoint(x: geometrySize.width/2, y: geometrySize.height/2),
radius: geometrySize.width/2 - width/2 - CGFloat(index) * (width + spacing),
startAngle: .degrees(0),
endAngle: .degrees(Double(Int.random(in: 120...300))),
clockwise: true)
return p.strokedPath(.init(lineWidth: width))
}
.frame(width: geometrySize.width, height: geometrySize.height)
}
}
struct ArcsAnimationModifier: ViewModifier {
#Binding var isAnimating: Bool
func body(content: Content) -> some View {
ZStack {
if isAnimating {
ArcsAnimationView(isAnimating: _isAnimating)
.frame(width: 150)
}
content
.disabled(isAnimating)
}
}
}
extension View {
func loadingAnimation(isAnimating: Binding<Bool>) -> some View {
self.modifier(ArcsAnimationModifier(isAnimating: isAnimating))
}
}
Here is where I actually call the function:
struct AnimationView: View {
#State var isAnimating = false
var body: some View {
VStack {
Button(action: {
self.isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
self.isAnimating = false
}
}, label: {
Text("show animation")
})
}
.loadingAnimation(isAnimating: $isAnimating)
}
}
Note: I am fairly certain the issue is with View Modifier since if I call ArcsAnimationView as a regular view in AnimationView, it works as expected.
I get there to see some implementation, but I think others would prefer a simple base to start from.
here my 2 cents to show how to write an AnimatableModifier that can be used on multiple objects cleaning up ".animation" in code.
struct ContentView: View {
#State private var hideWhilelUpdating = false
var body: some View {
Image(systemName: "tshirt.fill")
.modifier(SmoothHideAndShow(hide: hideWhilelUpdating))
Text("Some contents to show...")
.modifier(SmoothHideAndShow(hide: hideWhilelUpdating))
Button( "hide and show smootly") {
hideWhilelUpdating.toggle()
}
.padding(60)
}
}
struct SmoothHideAndShow: AnimatableModifier {
var hide: Bool
var animatableData: CGFloat {
get { CGFloat(hide ? 0 : 1) }
set { hide = newValue == 0 }
}
func body(content: Content) -> some View {
content
.opacity(hide ? 0.2 : 1)
.animation(.easeIn(duration: 1), value: hide)
}
}
when pressing button, our bool will trigger animation that fades in and out our text.
I use it during network calls (omitted for clarity... and replaced with button) to hide values under remote update. When network returns, I toggle boolean.
I'm trying to deal groups of cards from a deck, and have the cards fly from the deck to their respective spots on screen (Stanford 2021 IOS class SET game). I'm tracking the status of each card, including whether it has been dealt or not. I thought I had all the logic correct, but the animation using a matchedGeometryEffect is not working. Both in the canvas view and when running the application in the simulator, the cards just go to their final spots in the overall View, and do not animate individually.
In an earlier version, I was seeing multiple debugger messages about "Multiple inserted views in matched geometry group Pair<Int, ID> have 'isSource: true'", but those messages no longer appear. With this slimmed-down version, the cards simply fail to animate between their original positions (in the card deck) and their final positions in the VGrid.
This codes uses a modified LazyVGrid to display the cards, with a control called AspectVGrid. The primary difference between the original LazyVGrid and the AspectVGrid is that the AspectVGrid creates a LazyVGrid with a fixed aspect ratio. The AspectVGrid only controls the grid layout itself. The code for that struct is included below.
When the application starts, all the cards are available in the deck, and each card's view is assigned its source matchedGeometryEffect. The cards are all marked as undealt (in a #State Set of card IDs). When the deck is tapped (onTapGesture), the model updates either twelve or three additional cards as wasDealt, and those newly dealt cards are supposed to be animated via a "withAnimation" code block, with individual Views in the AspectVGrid as their destinations.
Any suggestions on how to resolve this would be welcome. This seems like a pretty straightforward process, but I am clearly missing something in my implementation.
Thanks in advance for any ideas on this. I've included the model, View Model, and Views below.
Model
import Foundation
import SwiftUI
struct dataModel {
struct Card: Identifiable {
var wasDealt: Bool
var wasDiscarded: Bool
var dealDelay: Double
let id: Int
}
private(set) var firstCardWasDealt: Bool = false
private(set) var cards: Array<Card>
var cardsDealt: Array<Card> {
get {cards.filter({ card in card.wasDealt})}
}
private var numberOfCardsInPlay: Int {
get { cardsDealt.count }
}
var cardsDisplayed: Array<Card> {
get {cardsDealt.filter({ card in !card.wasDiscarded })}
}
var cardsInDeck: Array<Card> {
get {cards.filter({ card in !card.wasDealt })}
}
init() {
cards = []
newGame()
}
// Divides the total time to deal the cards by the number of cards to deal,
// and provides the applicable delay to the nth (index) card in the group of
// cards to be dealt.
private func calcDelay(numCards: Int, index: Int, totalDelay: Double) -> Double {
return Double(index) * (totalDelay / Double(numCards))
}
// If no cards have been dealt, deal twelve cards. Otherwise, deal three cards.
// When dealing the cards, apply the appropriate delay to each card being dealt.
// If all cards are already in play, don't deal any more cards.
mutating func dealMoreCards() {
if !firstCardWasDealt {
for index in (0...11) {
cards[index].dealDelay = calcDelay(numCards: 12, index: index, totalDelay: CardConstants.total12CardDealDuration)
cards[index].wasDealt = true
}
firstCardWasDealt = true
} else {
if numberOfCardsInPlay < cards.count {
let startIndex = numberOfCardsInPlay
for index in (startIndex...(startIndex + 2)) {
cards[index].dealDelay = calcDelay(numCards: 3, index: index, totalDelay: CardConstants.total3CardDealDuration)
cards[index].wasDealt = true
}
}
}
}
mutating func newGame() {
firstCardWasDealt = false
cards = []
for index in (0...80) {
cards.append(Card(wasDealt: false, wasDiscarded: false, dealDelay: 0, id: index))
}
}
}
struct CardConstants {
static let color = Color.red
static let aspectRatio: CGFloat = 2/3
static let dealAnimationDuration: Double = 0.2 // 0.5 - This value controls how long it takes to animate each card
static let total12CardDealDuration: Double = 3.0 // this controls how long it takes to deal twelve cards (in seconds)
static let total3CardDealDuration: Double = 0.75 // this controls how long it takes to deal three cards (in seconds)
}
View Model
import SwiftUI
class ViewModel: ObservableObject {
#Published private var model: dataModel
init() {
model = dataModel()
}
var cardsDealt: Array<dataModel.Card> {
model.cardsDealt
}
var cardsDisplayed: Array<dataModel.Card> {
model.cardsDisplayed
}
var cardsInDeck: Array<dataModel.Card> {
model.cardsInDeck
}
func choose(_ card: dataModel.Card) {
// do something with the chosen card
}
func dealMoreCards() {
model.dealMoreCards()
}
}
View
import SwiftUI
struct exampleView: View {
#ObservedObject var example: ViewModel
#Namespace private var dealingNamespace
#State private var dealt = Set<Int>()
private func deal(_ card: dataModel.Card) {
dealt.insert(card.id)
}
private func isUnDealt(_ card: dataModel.Card) -> Bool {
!dealt.contains(card.id)
}
var body: some View {
VStack {
Text("Example - \(example.cardsDisplayed.count) cards Displayed")
AspectVGrid(items: example.cardsDisplayed, aspectRatio: 2/3, content: { card in CardView(card: card)
.padding(4)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .opacity))
.onTapGesture {
example.choose(card)
}
})
HStack {
deckBody
Spacer()
}
}
}
var deckBody: some View {
ZStack {
ForEach(example.cardsInDeck) {
card in CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
}
}
.frame(width: 60, height: 90)
.foregroundColor(.cyan)
.onTapGesture {
example.dealMoreCards() // Make the next group of cards available (12 or 3)
// deal cards
for card in example.cardsDealt {
if isUnDealt(card) {
withAnimation(Animation.easeInOut(duration: CardConstants.dealAnimationDuration).delay(card.dealDelay)) {
deal(card)
}
}
}
}
}
}
struct CardView: View {
let card: dataModel.Card
var body: some View {
VStack {
ZStack {
let shape = RoundedRectangle(cornerRadius: 5)
if card.wasDealt {
shape.fill().foregroundColor(.white)
} else {
shape.fill().foregroundColor(.cyan)
}
shape.strokeBorder(lineWidth: 3)
Text("Card: \(card.id)")
.padding(4)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let example = ViewModel()
exampleView(example: example)
}
}
AspectVGrid
//
// AspectVGrid.swift
//
// Created by CS193p Instructor on 4/14/21.
// Copyright Stanford University 2021
//
import SwiftUI
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
VStack {
let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
}
Spacer(minLength: 0)
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
var columnCount = 1
var rowCount = itemCount
repeat {
let itemWidth = size.width / CGFloat(columnCount)
let itemHeight = itemWidth / itemAspectRatio
if CGFloat(rowCount) * itemHeight < size.height {
break
}
columnCount += 1
rowCount = (itemCount + (columnCount - 1)) / columnCount
} while columnCount < itemCount
if columnCount > itemCount {
columnCount = itemCount
}
return floor(size.width / CGFloat(columnCount))
}
}
MatchedGeometryEffectApp
import SwiftUI
#main
struct MatchedGeometryEffectApp: App {
private let example = ViewModel()
var body: some Scene {
WindowGroup {
exampleView(example: example)
}
}
}
So, the problem you have been having with the matched geometry seems to be that AspectVGrid, which you were given as part of the assignment, interferes with the matched geometry animation. I suspect it has to do with the resizing mechanism. Removing that gives you a matched geometry that does what one would expect.
Here is a working example of the matched geometry in your ExampleView. I also rewrote CardView to make it simpler.
struct ExampleView: View {
#ObservedObject var example: ViewModel
#Namespace private var dealingNamespace
#State private var dealt = Set<Int>()
private func deal(_ card: DataModel.Card) {
dealt.insert(card.id)
}
private func isUnDealt(_ card: DataModel.Card) -> Bool {
!dealt.contains(card.id)
}
let columns = [
GridItem(.adaptive(minimum: 80))
]
var body: some View {
VStack {
VStack {
Text("Example - \(example.cardsDisplayed.count) cards Displayed")
LazyVGrid(columns: columns, spacing: 10) {
ForEach(example.cardsDisplayed) { card in
CardView(card: card)
.aspectRatio(2/3, contentMode: .fit)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.onTapGesture {
example.choose(card)
}
.padding(4)
}
}
Spacer()
HStack {
deckBody
Spacer()
}
}
}
}
var deckBody: some View {
ZStack {
ForEach(example.cardsInDeck) { card in
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
}
}
.frame(width: 60, height: 90)
.foregroundColor(.cyan)
.onTapGesture {
withAnimation {
example.dealMoreCards() // Make the next group of cards available (12 or 3)
// deal cards
for card in example.cardsDealt {
if isUnDealt(card) {
deal(card)
}
}
}
}
}
}
struct CardView: View {
let card: DataModel.Card
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5)
.fill()
.foregroundColor(card.wasDealt ? .white : .cyan)
RoundedRectangle(cornerRadius: 5)
.strokeBorder(lineWidth: 3)
Text("Card: \(card.id)")
.padding(4)
}
}
}
A quick note about naming conventions. Types and structs should all be capitalized, so ExampleView and DataModel would be convention, and I changed the code to reflect that. You will need to fix yours when you incorporate this code.
Also note that this view isn't perfect. Eventually, it will push the deck of cards off the bottom of the screen, but that can be handled.
Your attempt to make the cards fly one at a time doesn't work. First, the withAnimation has to capture example.dealMoreCards() and trying to implement a .delay in that situation doesn't work. It can be implemented with a DispatchQueue.main.asyncAfter(deadline:), but you would have to rework how you pass the timing as the individual cards won't be available yet.
Lastly, the transitions do nothing in this situation.
According to my logic, on tap gesture to the image it should be changed with fade animation, but actual result is that image changes without animation. Tested with Xcode 11.3.1, Simulator 13.2.2/13.3 if it is important.
P.S. Images are named as "img1", "img2", "img3", etc.
enum ImageEnum: String {
case img1
case img2
case img3
func next() -> ImageEnum {
switch self {
case .img1: return .img2
case .img2: return .img3
case .img3: return .img1
}
}
}
struct ContentView: View {
#State private var img = ImageEnum.img1
var body: some View {
Image(img.rawValue)
.onTapGesture {
withAnimation {
self.img = self.img.next()
}
}
}
}
Update: re-tested with Xcode 13.3 / iOS 15.4
Here is possible approach using one Image (for demo some small modifications made to use default images). The important changes marked with comments.
enum ImageEnum: String {
case img1 = "1.circle"
case img2 = "2.circle"
case img3 = "3.circle"
func next() -> ImageEnum {
switch self {
case .img1: return .img2
case .img2: return .img3
case .img3: return .img1
}
}
}
struct QuickTest: View {
#State private var img = ImageEnum.img1
#State private var fadeOut = false
var body: some View {
Image(systemName: img.rawValue).resizable().frame(width: 300, height: 300)
.opacity(fadeOut ? 0 : 1)
.animation(.easeInOut(duration: 0.25), value: fadeOut) // animatable fade in/out
.onTapGesture {
self.fadeOut.toggle() // 1) fade out
// delayed appear
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
withAnimation {
self.img = self.img.next() // 2) change image
self.fadeOut.toggle() // 3) fade in
}
}
}
}
}
I haven't tested this code, but something like this might be a bit simpler:
struct ContentView: View {
#State private var img = ImageEnum.img1
var body: some View {
Image(img.rawValue)
.id(img.rawValue)
.transition(.opacity.animation(.default))
.onTapGesture {
withAnimation {
self.img = self.img.next()
}
}
}
}
The idea is tell SwiftUI to redraw the Image whenever the filename of the asset changes by binding the View's identity to the filename itself. When the filename changes, SwiftUI assumes the View changed and a new View must be added to the view hierarchy, thus the transition is triggered.
SwiftUI animations are typically driven by state, which is great, but sometimes you really want to trigger a temporary (often reversible) animation in response to some event. For example, I want to temporarily increase the size of a button when a it is tapped (both the increase and decrease in size should happen as a single animation when the button is released), but I haven't been able to figure this out.
It can sort of be hacked together with transitions I think, but not very nicely. Also, if I make an animation that uses autoreverse, it will increase the size, decrease it and then jump back to the increased state.
That is something I have been into as well.
So far my solution depends on applying GeometryEffect modifier and misusing the fact that its method effectValue is called continuously during some animation. So the desired effect is actually a transformation of interpolated values from 0..1 that has the main effect in 0.5 and no effect at 0 or 1
It works great, it is applicable to all views not just buttons, no need to depend on touch events or button styles, but still sort of seems to me as a hack.
Example with random rotation and scale effect:
Code sample:
struct ButtonEffect: GeometryEffect {
var offset: Double // 0...1
var animatableData: Double {
get { offset }
set { offset = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let effectValue = abs(sin(offset*Double.pi))
let scaleFactor = 1+0.2*effectValue
let affineTransform = CGAffineTransform(rotationAngle: CGFloat(effectValue)).translatedBy(x: -size.width/2, y: -size.height/2).scaledBy(x: CGFloat(scaleFactor), y: CGFloat(scaleFactor))
return ProjectionTransform(affineTransform)
}
}
struct ButtonActionView: View {
#State var animOffset: Double = 0
var body: some View {
Button(action:{
withAnimation(.spring()) {
self.animOffset += 1
}
})
{
Text("Press ME")
.padding()
}
.background(Color.yellow)
.modifier(ButtonEffect(offset: animOffset))
}
}
You can use a #State variable tied to a longPressAction():
Code updated for Beta 5:
struct ContentView: View {
var body: some View {
HStack {
Spacer()
MyButton(label: "Button 1")
Spacer()
MyButton(label: "Button 2")
Spacer()
MyButton(label: "Button 3")
Spacer()
}
}
}
struct MyButton: View {
let label: String
#State private var pressed = false
var body: some View {
return Text(label)
.font(.title)
.foregroundColor(.white)
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).foregroundColor(.green))
.scaleEffect(self.pressed ? 1.2 : 1.0)
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
withAnimation(.easeInOut(duration: 0.2)) {
self.pressed = pressing
}
}, perform: { })
}
}
I believe this is what you're after. (this is how I solved this problem)
Based on dfd's link in i came up with this, which is not dependent on any #State variable. You simply just implement your own button style.
No need for Timers, #Binding, #State or other complex workarounds.
import SwiftUI
struct MyCustomPressButton: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(10)
.cornerRadius(10)
.scaleEffect(configuration.isPressed ? 0.8 : 1.0)
}
}
struct Play: View {
var body: some View {
Button("Tap") {
}.buttonStyle(MyCustomPressButton())
.animation(.easeIn(duration: 0.2))
}
}
struct Play_Previews: PreviewProvider {
static var previews: some View {
Play()
}
}
There is no getting around the need to update via state in SwiftUI. You need to have some property that is only true for a short time that then toggles back.
The following animates from small to large and back.
struct ViewPlayground: View {
#State var enlargeIt = false
var body: some View {
Button("Event!") {
withAnimation {
self.enlargeIt = true
}
}
.background(Momentary(doIt: self.$enlargeIt))
.scaleEffect(self.enlargeIt ? 2.0 : 1.0)
}
}
struct Momentary: View {
#Binding var doIt: Bool
var delay: TimeInterval = 0.35
var body: some View {
Group {
if self.doIt {
ZStack { Spacer() }
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + self.delay) {
withAnimation {
self.doIt = false
}
}
}
}
}
}
}
Unfortunately delay was necessary to get the animation to occur when setting self.enlargeIt = true. Without that it only animates back down. Not sure if that's a bug in Beta 4 or not.
I've been looking through the docs with each beta but haven't seen a way to make a traditional paged ScrollView. I'm not familiar with AppKit so I am wondering if this doesn't exist in SwiftUI because it's primarily a UIKit construct. Anyway, does anyone have an example of this, or can anyone tell me it's definitely impossible so I can stop looking and roll my own?
You can now use a TabView and set the .tabViewStyle to PageTabViewStyle()
TabView {
View1()
View2()
View3()
}
.tabViewStyle(PageTabViewStyle())
As of Beta 3 there is no native SwiftUI API for paging. I've filed feedback and recommend you do the same. They changed the ScrollView API from Beta 2 to Beta 3 and I wouldn't be surprised to see a further update.
It is possible to wrap a UIScrollView in order to provide this functionality now. Unfortunately, you must wrap the UIScrollView in a UIViewController, which is further wrapped in UIViewControllerRepresentable in order to support SwiftUI content.
Gist here
class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = true
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
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 UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
}
}
And then to use it:
var body: some View {
GeometryReader { proxy in
UIScrollViewWrapper {
VStack {
ForEach(0..<1000) { _ in
Text("Hello world")
}
}
.frame(width: proxy.size.width) // This ensures the content uses the available width, otherwise it will be pinned to the left
}
}
}
Apple's official tutorial covers this as an example. I find it easy to follow and suitable for my case. I really recommend you check this out and try to understand how to interface with UIKit. Since SwiftUI is so young, not every feature in UIKit would be covered at this moment. Interfacing with UIKit should address most if not all needs.
https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit
Not sure if this helps your question but for the time being while Apple is working on adding a Paging View in SwiftUI I've written a utility library that gives you a SwiftUI feel while using a UIPageViewController under the hood tucked away.
You can use it like this:
Pages {
Text("Page 1")
Text("Page 2")
Text("Page 3")
Text("Page 4")
}
Or if you have a list of models in your application you can use it like this:
struct Car {
var model: String
}
let cars = [Car(model: "Ford"), Car(model: "Ferrari")]
ModelPages(cars) { index, car in
Text("The \(index) car is a \(car.model)")
.padding(50)
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(10)
}
You can simply track state using .onAppear() to load your next page.
struct YourListView : View {
#ObservedObject var viewModel = YourViewModel()
let numPerPage = 50
var body: some View {
NavigationView {
List(viewModel.items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
.onAppear {
if self.shouldLoadNextPage(currentItem: item) {
self.viewModel.fetchItems(limitPerPage: self.numPerPage)
}
}
}
}
.navigationBarTitle(Text("Items"))
.onAppear {
guard self.viewModel.items.isEmpty else { return }
self.viewModel.fetchItems(limitPerPage: self.numPerPage)
}
}
}
private func shouldLoadNextPage(currentItem item: Item) -> Bool {
let currentIndex = self.viewModel.items.firstIndex(where: { $0.id == item.id } )
let lastIndex = self.viewModel.items.count - 1
let offset = 5 //Load next page when 5 from bottom, adjust to meet needs
return currentIndex == lastIndex - offset
}
}
class YourViewModel: ObservableObject {
#Published private(set) items = [Item]()
// add whatever tracking you need for your paged API like next/previous and count
private(set) var fetching = false
private(set) var next: String?
private(set) var count = 0
func fetchItems(limitPerPage: Int = 30, completion: (([Item]?) -> Void)? = nil) {
// Do your stuff here based on the API rules for paging like determining the URL etc...
if items.count == 0 || items.count < count {
let urlString = next ?? "https://somePagedAPI?limit=/(limitPerPage)"
fetchNextItems(url: urlString, completion: completion)
} else {
completion?(pokemon)
}
}
private func fetchNextItems(url: String, completion: (([Item]?) -> Void)?) {
guard !fetching else { return }
fetching = true
Networking.fetchItems(url: url) { [weak self] (result) in
DispatchQueue.main.async { [weak self] in
self?.fetching = false
switch result {
case .success(let response):
if let count = response.count {
self?.count = count
}
if let newItems = response.results {
self?.items += newItems
}
self?.next = response.next
case .failure(let error):
// Error state tracking not implemented but would go here...
os_log("Error fetching data: %#", error.localizedDescription)
}
}
}
}
}
Modify to fit whatever API you are calling and handle errors based on your app architecture.
Checkout SwiftUIPager. It's a pager built on top of SwiftUI native components:
If you would like to exploit the new PageTabViewStyle of TabView, but you need a vertical paged scroll view, you can make use of effect modifiers like .rotationEffect().
Using this method I wrote a library called VerticalTabView 🔝 that turns a TabView vertical just by changing your existing TabView to VTabView.
You can use such custom modifier:
struct ScrollViewPagingModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
.onDisappear {
UIScrollView.appearance().isPagingEnabled = false
}
}
}
extension ScrollView {
func isPagingEnabled() -> some View {
modifier(ScrollViewPagingModifier())
}
}
To simplify Lorenzos answer, you can basically add UIScrollView.appearance().isPagingEnabled = true to your scrollview as below:
VStack{
ScrollView(showsIndicators: false){
VStack(spacing: 0){ // to remove spacing between rows
ForEach(1..<10){ i in
ZStack{
Text(String(i))
Circle()
} .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
}
}
}.onAppear {
UIScrollView.appearance().isPagingEnabled = true
}
.onDisappear {
UIScrollView.appearance().isPagingEnabled = false
}
}