Skip to content

Instantly share code, notes, and snippets.

@maximkrouk
Last active May 30, 2020 14:19
Show Gist options
  • Save maximkrouk/e4bae2b4d7422cd35ae591ad8b59aed1 to your computer and use it in GitHub Desktop.
Save maximkrouk/e4bae2b4d7422cd35ae591ad8b59aed1 to your computer and use it in GitHub Desktop.
3D animated tinder-inspired swipable cards, written in SwiftUI
public struct CardView<Content: View>: View {
@ObservedObject
private var offset = Box<CGSize>(.zero)
private var content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
private var _onRemove: ((Bool) -> Void)?
private var _nextView: Content?
private var initialScaleEffect: Double { 0.7 }
public var body: some View {
ZStack {
if _nextView != nil {
_nextView!.scaleEffect(
nextContentScaleEffect(),
anchor: .center
)
.blur(radius: CGFloat(currentContentVisibility() * 5))
.opacity(nextContentVisibility())
}
Group {
content
.overlay(customOverlay())
.offset(x: offset.width * 2, y: 0)
.rotation3DEffect(
.degrees(rotationAngle),
axis: (-1, 0, 0)
)
.rotation3DEffect(
.degrees(rotationAngle),
axis: (0, 1, 0)
)
.rotation3DEffect(
.degrees(rotationAngle),
axis: (0, 0, 1)
)
.opacity(currentContentVisibility())
}
.gesture(DragGesture()
.onChanged { value in
self.offset.content = value.translation
}
.onEnded { value in
if self.currentContentVisibility(for: value.predictedEndTranslation.width) < 0.5 {
withAnimation(.easeOut(duration: 0.5)) {
var width = value.predictedEndTranslation.width
var height = value.predictedEndTranslation.height
if abs(width) < 200 { width = width / abs(width) * 200 }
else if abs(width) > 750 { width = width / abs(width) * 750 }
if abs(height) < 200 { height = height / abs(height) * 200 }
else if abs(height) > 750 { height = height / abs(height) * 750 }
self.offset.content = .init(width: width, height: height)
}
DispatchQueue.main.asyncAfter(deadline: .milliseconds(500)) {
self.offset.content = .zero
self._onRemove?(!value.predictedEndTranslation.width.isLess(than: 0))
}
} else {
withAnimation(.easeInOut(duration: 0.5)) {
self.offset.content = .zero
}
}
})
}
.pin.toSuperview()
}
// TODO: Extract logic from this generic card container
private func customOverlay() -> some View {
let sfSymbol: SFSymbol = {
if offset.width > 0 {
return .heartCircleFill
} else if offset.width < 0 {
return .nosign
} else {
return .circle
}
}()
let color: Color = {
if offset.width > 0 {
return .green
} else if offset.width < 0 {
return .red
} else {
return .contrast(.low)
}
}()
return ZStack {
Image(sfSymbol)
.font(.system(size: 100))
.foregroundColor(color.opacity(0.7))
}
.pin.toSuperview()
.background(Color.contrast(.lower).opacity(0.2))
.opacity(abs(Double(offset.width) * 0.01))
.eraseToAnyView()
}
private var rotationAngle: Double { Double(offset.height / 20) }
private func currentContentVisibility(for translationX: CGFloat) -> Double {
min(max(2 - Double(abs(translationX * 0.01)), 0), 1)
}
private func currentContentVisibility() -> Double {
currentContentVisibility(for: offset.width)
}
private func nextContentVisibility() -> Double {
1 - min(max(1 - Double(abs(offset.width * 0.005)), 0), 1)
}
private func nextContentScaleEffect() -> CGFloat {
CGFloat(initialScaleEffect + (1 - initialScaleEffect) * nextContentVisibility())
}
public func onRemove(perform: ((Bool) -> Void)?) -> Self {
return Builder<Self>(self)
.set(\._onRemove, perform)
.build()
}
public func nextView(content: Content?) -> Self {
return Builder<Self>(self)
.set(\._nextView, content)
.build()
}
}
public struct Builder<T> {
private var initial: T
private var transform: (T) -> T = { $0 }
public init(_ initialize: @escaping () -> T) { self.init(initialize()) }
public init(_ initial: T) { self.initial = initial }
public func set<Value>(_ keyPath: WritableKeyPath<T, Value>, _ value: Value) -> Self {
self.set(keyPath == value)
}
public func set(_ transform: @escaping (inout T) -> Void) -> Self {
modification(of: self) { _self in
_self.transform = { object in
modification(of: self.transform(object), transform: transform)
}
}
}
public func set(_ transform: @escaping (T) -> T) -> Self {
modification(of: self) { _self in
_self.transform = { object in
transform(self.transform(object))
}
}
}
public func build() -> T { transform(initial) }
}
public func ==<Object, Value>(_ lhs: WritableKeyPath<Object, Value>, _ rhs: Value)
-> (Object) -> Object {
return { object in
modification(of: object) { $0[keyPath: lhs] = rhs }
}
}
public func modification<Object>(
of object: Object,
transform: (inout Object) -> Void
) -> Object {
var object = object
transform(&object)
return object
}
import Combine
@dynamicMemberLookup
public class Box<Content>: ObservableObject {
@Published
public var content: Content
public var publisher: some Publisher { $content }
public init(_ content: Content) {
self.content = content
}
public subscript<T>(dynamicMember keyPath: KeyPath<Content, T>) -> T {
get { content[keyPath: keyPath] }
}
public subscript<T>(dynamicMember keyPath: WritableKeyPath<Content, T>) -> T {
get { content[keyPath: keyPath] }
set { content[keyPath: keyPath] = newValue }
}
public subscript<T>(dynamicMember keyPath: ReferenceWritableKeyPath<Content, T>) -> T {
get { content[keyPath: keyPath] }
set { content[keyPath: keyPath] = newValue }
}
}
@maximkrouk
Copy link
Author

maximkrouk commented May 2, 2020

InApp Example
ezgif com-resize

See more:

Back to index

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment