Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
SwiftUI MicroAnimation example
//
// TipsView.swift
// Stonks
//
// Created by Paul Stamatiou on 7/3/20.
//
import SwiftUI
import PlaygroundSupport
struct TipsView: View {
@State private var debutTip1: Bool = false
@State private var showTip1: Bool = false
@State private var Tip1Appear: Bool = false
@State private var Tip1AppearDelay: Bool = false
@State private var resetTip1: Bool = false
@State private var loopTip1Active: Bool = true
let frameSizeX: Double = 86
let frameSizeY: Double = 60
// CurveAlgorithm() is a version of https://gist.github.com/nhatminh12369/29c4266b1a3b87048f810bc26e1bbd39#file-curvealgorithm-swift
// I modified to use Path() instead of UIBezierPath() along with the different addCurve() syntax
let curve = CurveAlgorithm()
func loopTip1() -> Void {
// scale in the rect
self.debutTip1 = true
// Animate the line in
self.Tip1Appear = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
// scale effect to simulate tap bounce
self.Tip1AppearDelay = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
// undo tap bounce - could have also done via repeat once animation modifier
self.Tip1AppearDelay = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
// Complete rest of animations like expanding and showing stats
self.resetTip1 = false
self.showTip1 = true
// rest for 2s then repeat
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
// rest at completed animation for 5 seconds
self.resetTip1 = true
self.debutTip1 = false
self.showTip1 = false
self.Tip1Appear = false
self.Tip1AppearDelay = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
// state used to cancel animation loop when going to the next one
if self.loopTip1Active {
self.loopTip1()
}
}
}
}
}
}
}
var body: some View {
ZStack {
Color.black.opacity(0.4)
.edgesIgnoringSafeArea(.all)
ZStack {
LinearGradient(
gradient: Gradient(colors: [.purple, .blue, .green]),
startPoint: .init(x: 0.3, y: -0.25),
endPoint: .init(x: 0.8, y: 1.3)
)
// https://github.com/twostraws/VisualEffects
VisualEffectBlur(blurStyle: .systemThinMaterialLight, vibrancyStyle: .fill) {
VStack {
Text("THE BASICS")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.opacity(0.6)
.padding(.bottom, 10)
Spacer()
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white, lineWidth: 2)
.frame(width: 90, height: self.showTip1 ? 100 : 80)
.opacity(0.6)
.animation(Animation.spring(response: 0.8, dampingFraction: 1, blendDuration: 0).delay(0))
Text("AAPL")
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.black)
.offset(x: 11, y: 9)
.frame(width: 90, height: self.showTip1 ? 100 : 80, alignment: .topLeading)
VStack {
curve.createCurvedPath(
[CGPoint(x: self.frameSizeX * 0, y: self.frameSizeY * 0.7),
CGPoint(x: self.frameSizeX * 0.25, y: self.frameSizeY * 0.55),
CGPoint(x: self.frameSizeX * 0.5, y: self.frameSizeY * 0.7),
CGPoint(x: self.frameSizeX * 0.9, y: self.frameSizeY * 0.3),
CGPoint(x: self.frameSizeX * 1, y: self.frameSizeY * 0.35)]
)
.trim(from: 0, to: self.Tip1Appear ? 1 : 0)
.stroke(Color.black, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
.animation(Animation.spring(response: 1, dampingFraction: 1, blendDuration: 0).delay(0.4))
.frame(width: 88, height: self.showTip1 ? 100 : 80, alignment: .topLeading)
.offset(x: 1, y: 9)
.opacity(0.6)
}
VStack(alignment: .leading, spacing: 0) {
Spacer()
Rectangle()
.frame(width: self.showTip1 ? 88 : 0, height: 1.5, alignment: .topLeading)
.opacity(self.showTip1 ? 0.4 : 0)
.animation(self.resetTip1 ? .none : Animation.spring(response: 0.7, dampingFraction: 0.8, blendDuration: 0))
.offset(x: 6)
HStack(alignment: .top) {
ForEach(0..<5) { index in
VStack(alignment: .leading, spacing: 0) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.frame(width: 20, height: 3.5)
.offset(y: self.showTip1 ? 3 : -2)
.scaleEffect(x: self.showTip1 ? 1 : 0.25, y: 1, anchor: .leading)
.opacity(self.showTip1 ? 1 : 0)
.animation(self.resetTip1 ? .none : Animation.spring(response: 1, dampingFraction: 1, blendDuration: 0)
.delay(0.15 * Double(index)))
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.frame(width: 13, height: 3)
.offset(y: self.showTip1 ? 3 : -2)
.padding(.top, 5)
.scaleEffect(x: self.showTip1 ? 1 : 0.25, y: 1, anchor: .leading)
.opacity(self.showTip1 ? 1 : 0)
.animation(self.resetTip1 ? .none : Animation.spring(response: 1, dampingFraction: 1, blendDuration: 0)
.delay(0.25 * Double(index)))
}
.opacity(0.5)
.padding(.leading, 7)
.offset(x: self.showTip1 ? -92 : 0)
.animation(self.resetTip1 ? .none : Animation.spring(response: 2, dampingFraction: 1.5, blendDuration: 0).delay(0.75))
}
Spacer()
}
.frame(width: 90, height: 24, alignment: .leading)
.padding(.top, 0)
.padding(.leading, 10)
.padding(.bottom, 9)
}
.frame(width: 88, height: self.showTip1 ? 100 : 80, alignment: .bottom)
.animation(Animation.spring(response: 0.7, dampingFraction: 0.8, blendDuration: 0))
.offset(x: 0)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.scaleEffect(self.Tip1AppearDelay ? 0.94 : 1, anchor: .center)
.animation(Animation.spring(response: 0.6, dampingFraction: 0.8, blendDuration: 0).delay(0))
.scaleEffect(self.debutTip1 ? 1 : 0.9, anchor: .center)
.opacity(self.debutTip1 ? 1 : 0)
.animation(Animation.spring(response: 0.6, dampingFraction: 0.8, blendDuration: 0).delay(0))
.onAppear {
self.loopTip1()
}
Spacer()
Text("Expand")
.font(.system(size: 24, weight: .bold))
.foregroundColor(Color.black)
Text("Tap to expand to see \nmore stock stats")
.font(.system(size: 18, weight: .medium))
.foregroundColor(Color.black)
.multilineTextAlignment(.center)
.padding(.top, 10)
.padding(.bottom, 20)
Button(action: {
// build guided pager view or something
}) {
Text("Next")
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.padding(.top, 10)
}
}
.padding(.horizontal, 32)
.padding(.top, 12)
.padding(.bottom, 30)
}
.opacity(0.6)
}
.frame(width: screen.width - 60, height: 370)
.clipShape(RoundedRectangle(cornerRadius: 25.0, style: .continuous))
}
}
}
struct CurvedSegment {
var controlPoint1: CGPoint
var controlPoint2: CGPoint
}
class CurveAlgorithm {
static let shared = CurveAlgorithm()
private func controlPointsFrom(points: [CGPoint]) -> [CurvedSegment] {
var result: [CurvedSegment] = []
let delta: CGFloat = 0.3 // The value that help to choose temporary control points.
// Calculate temporary control points, these control points make Bezier segments look straight and not curving at all
for i in 1..<points.count {
let A = points[i-1]
let B = points[i]
let controlPoint1 = CGPoint(x: A.x + delta*(B.x-A.x), y: A.y + delta*(B.y - A.y))
let controlPoint2 = CGPoint(x: B.x - delta*(B.x-A.x), y: B.y - delta*(B.y - A.y))
let curvedSegment = CurvedSegment(controlPoint1: controlPoint1, controlPoint2: controlPoint2)
result.append(curvedSegment)
}
// Calculate good control points
for i in 1..<points.count-1 {
/// A temporary control point
let M = result[i-1].controlPoint2
/// A temporary control point
let N = result[i].controlPoint1
/// central point
let A = points[i]
/// Reflection of M over the point A
let MM = CGPoint(x: 2 * A.x - M.x, y: 2 * A.y - M.y)
/// Reflection of N over the point A
let NN = CGPoint(x: 2 * A.x - N.x, y: 2 * A.y - N.y)
result[i].controlPoint1 = CGPoint(x: (MM.x + N.x)/2, y: (MM.y + N.y)/2)
result[i-1].controlPoint2 = CGPoint(x: (NN.x + M.x)/2, y: (NN.y + M.y)/2)
}
return result
}
/**
Create a curved bezier path that connects all points in the dataset
*/
func createCurvedPath(_ dataPoints: [CGPoint]) -> Path {
var path = Path()
path.move(to: dataPoints[0])
var curveSegments: [CurvedSegment] = []
curveSegments = controlPointsFrom(points: dataPoints)
for i in 1..<dataPoints.count {
path.addCurve(to: dataPoints[i], control1: curveSegments[i-1].controlPoint1, control2: curveSegments[i-1].controlPoint2)
}
return path
}
}
/*
Copyright © 2020 Apple Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
// https://github.com/twostraws/VisualEffects
import SwiftUI
#if os(iOS)
public struct VisualEffectBlur<Content: View>: View {
/// Defaults to .systemMaterial
var blurStyle: UIBlurEffect.Style
/// Defaults to nil
var vibrancyStyle: UIVibrancyEffectStyle?
var content: Content
public init(blurStyle: UIBlurEffect.Style = .systemMaterial, vibrancyStyle: UIVibrancyEffectStyle? = nil, @ViewBuilder content: () -> Content) {
self.blurStyle = blurStyle
self.vibrancyStyle = vibrancyStyle
self.content = content()
}
public var body: some View {
Representable(blurStyle: blurStyle, vibrancyStyle: vibrancyStyle, content: ZStack { content })
.accessibility(hidden: Content.self == EmptyView.self)
}
}
// MARK: - Representable
extension VisualEffectBlur {
struct Representable<Content: View>: UIViewRepresentable {
var blurStyle: UIBlurEffect.Style
var vibrancyStyle: UIVibrancyEffectStyle?
var content: Content
func makeUIView(context: Context) -> UIVisualEffectView {
context.coordinator.blurView
}
func updateUIView(_ view: UIVisualEffectView, context: Context) {
context.coordinator.update(content: content, blurStyle: blurStyle, vibrancyStyle: vibrancyStyle)
}
func makeCoordinator() -> Coordinator {
Coordinator(content: content)
}
}
}
// MARK: - Coordinator
extension VisualEffectBlur.Representable {
class Coordinator {
let blurView = UIVisualEffectView()
let vibrancyView = UIVisualEffectView()
let hostingController: UIHostingController<Content>
init(content: Content) {
hostingController = UIHostingController(rootView: content)
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostingController.view.backgroundColor = nil
blurView.contentView.addSubview(vibrancyView)
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
vibrancyView.contentView.addSubview(hostingController.view)
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
func update(content: Content, blurStyle: UIBlurEffect.Style, vibrancyStyle: UIVibrancyEffectStyle?) {
hostingController.rootView = content
let blurEffect = UIBlurEffect(style: blurStyle)
blurView.effect = blurEffect
if let vibrancyStyle = vibrancyStyle {
vibrancyView.effect = UIVibrancyEffect(blurEffect: blurEffect, style: vibrancyStyle)
} else {
vibrancyView.effect = nil
}
hostingController.view.setNeedsDisplay()
}
}
}
// MARK: - Content-less Initializer
public extension VisualEffectBlur where Content == EmptyView {
init(blurStyle: UIBlurEffect.Style = .systemMaterial) {
self.init(blurStyle: blurStyle, vibrancyStyle: nil) {
EmptyView()
}
}
}
#endif
let screen = UIScreen.main.bounds
PlaygroundPage.current.setLiveView(TipsView())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.