Skip to content

Instantly share code, notes, and snippets.

@ThomasHack
Last active January 20, 2023 07:16
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ThomasHack/67e6182ce18397cfb59e63a70efeaff5 to your computer and use it in GitHub Desktop.
Save ThomasHack/67e6182ce18397cfb59e63a70efeaff5 to your computer and use it in GitHub Desktop.
SwiftUI App Store Card Animation
//
// ContentView.swift
// AppStoreCard
//
import ComposableArchitecture
import FetchImage
import SwiftUI
struct ContentView: View {
let store: Store<Main.State, Main.Action>
var body: some View {
WithViewStore(self.store) { viewStore in
OverviewView(store: Main.store.overview)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(store: Main.store)
}
}
// MARK: - Stories
struct Story: Equatable, Hashable {
var title: String
var description: String
var image: String?
}
enum Stories {
static let stories = [
Story(title: "Title #1", description: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.", image: "https://picsum.photos/480/320"),
Story(title: "Title #2", description: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum."),
Story(title: "Title #3", description: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum."),
Story(title: "Title #4", description: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.", image: "https://picsum.photos/480/320"),
Story(title: "Title #5", description: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.", image: "https://picsum.photos/480/320")
]
}
// MARK: - Main
enum Main {
struct State: Equatable {
var overview: Overview.State
}
enum Action {
case overview(Overview.Action)
}
struct Environment {
let mainQueue: AnySchedulerOf<DispatchQueue>
}
static let reducer = Reducer<State, Action, Environment>.combine(
Reducer<State, Action, Environment> { _, _, _ in
return .none
},
Overview.reducer.pullback(
state: \State.overview,
action: /Action.overview,
environment: { $0}
)
)
static let stories = Stories.stories
static let initialState = State(
overview: Overview.initialState
)
static let initialEnvironment = Environment(
mainQueue: DispatchQueue.main.eraseToAnyScheduler()
)
static let store = Store(
initialState: initialState,
reducer: reducer,
environment: initialEnvironment
)
}
extension Store where State == Main.State, Action == Main.Action {
var overview: Store<Overview.State, Overview.Action> {
scope(state: \.overview, action: Main.Action.overview)
}
}
// MARK: - Overview
enum Overview {
struct State: Equatable {
var stories: [Story]
var selectedStory: Story?
}
enum Action {
case didSelectItem(Story)
case didCloseItem
}
typealias Environment = Main.Environment
static let reducer = Reducer<State, Action, Environment> { state, action, environment in
switch action {
case .didSelectItem(let story):
state.selectedStory = story
case .didCloseItem:
state.selectedStory = nil
}
return .none
}
static let initialState = State(
stories: Main.stories,
selectedStory: nil
)
}
struct OverviewView: View {
let store: Store<Overview.State, Overview.Action>
var body: some View {
WithViewStore(store) { viewStore in
let scrolling: Axis.Set = .vertical // viewStore.selectedStory != nil ? [] : .vertical
ZStack {
Color(UIColor.secondarySystemBackground)
ScrollView(scrolling) {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text("Hi Tommy")
.font(.largeTitle)
.fontWeight(.bold)
Text("Discover the latest news")
.font(.callout)
}.padding(16)
ForEach(Stories.stories, id: \.self) { story in
let isExpanded = viewStore.selectedStory == story
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
let width = screenWidth
let height = isExpanded ? screenHeight : (story.image != nil ? 470 : 250)
GeometryReader { geometry in
CardView(store: self.store, story: story)
.offset(
x: isExpanded ? -geometry.frame(in: .global).minX : 0,
y: isExpanded ? -geometry.frame(in: .global).minY : 0
)
}
.frame(
width: width,
height: height
)
}
}
.padding(.top, 76)
.padding(.bottom, 24)
}
.shadow(color: Color.black.opacity(0.075), radius: 10, x: 10, y: 10)
}
.edgesIgnoringSafeArea(.all)
}
}
}
// MARK: - Card View
struct CardView: View {
let store: Store<Overview.State, Overview.Action>
let story: Story
var body: some View {
WithViewStore(store) { viewStore in
let isExpanded = viewStore.selectedStory == story
let scrolling: Axis.Set = .vertical // isExpanded ? .vertical : []
Group {
VStack(alignment: .leading, spacing: 16) {
if isExpanded {
Spacer()
.frame(height: 20)
HStack {
Spacer()
Button("Close") {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75, blendDuration: 0)) {
viewStore.send(.didCloseItem)
}
}
}
}
ScrollView(scrolling) {
VStack(alignment: .leading, spacing: 16) {
if isExpanded {
Text("3min to read")
.font(.caption)
}
Text(story.title)
.font(.system(size: 24, weight: .bold, design: .default))
if isExpanded {
Color.green.frame(width: 74, height: 4)
}
if let image = story.image, let url = URL(string: image) {
FetchableImage(image: FetchImage(url: url))
.clipped()
.cornerRadius(3)
}
Text(story.description)
if isExpanded {
Text(story.description)
Text(story.description)
}
}
}
}.padding(16)
}
.background(Color.white)
.cornerRadius(14)
.padding(isExpanded ? 0 : 16)
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75, blendDuration: 0)) {
viewStore.send(.didSelectItem(story))
}
}
}
}
}
// MARK: - Helper
struct FetchableImage: View {
@ObservedObject var image: FetchImage
var body: some View {
ZStack {
Rectangle().fill(Color.gray)
image.view?
.resizable()
.aspectRatio(contentMode: .fit)
}
.onAppear(perform: image.fetch)
.onDisappear(perform: image.cancel)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment