-
-
Save maximkrouk/e4bae2b4d7422cd35ae591ad8b59aed1 to your computer and use it in GitHub Desktop.
3D animated tinder-inspired swipable cards, written in SwiftUI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
InApp Example
See more:
Back to index