Last active
January 20, 2023 07:16
-
-
Save ThomasHack/67e6182ce18397cfb59e63a70efeaff5 to your computer and use it in GitHub Desktop.
SwiftUI App Store Card Animation
This file contains hidden or 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
// | |
// 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