Skip to content

Commit 00a61c2

Browse files
authored
Merge pull request #1 from ra1028/example-themoviedb
Add an example app using the movie DB API
2 parents 9b1b3ff + c5b2125 commit 00a61c2

17 files changed

+739
-1
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ jobs:
5050
- name: Build for watchOS
5151
run: set -o pipefail && xcodebuild build -scheme Hooks -configuration Release -sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 6 - 44mm' ENABLE_TESTABILITY=YES | xcpretty -c
5252

53+
- name: Build TheMovieDBExample
54+
run: set -o pipefail && xcodebuild build -scheme TheMovieDBExample -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 12 Pro' ENABLE_TESTABILITY=YES | xcpretty -c
55+
5356
- name: Build BasicExample
5457
run: set -o pipefail && xcodebuild build -scheme BasicExample -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 12 Pro' ENABLE_TESTABILITY=YES | xcpretty -c
5558

.swift-format.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"NoVoidReturnOnFunctionSignature": true,
4040
"OneCasePerLine": true,
4141
"OneVariableDeclarationPerLine": true,
42-
"OnlyOneTrailingClosureArgument": true,
42+
"OnlyOneTrailingClosureArgument": false,
4343
"OrderedImports": true,
4444
"ReturnVoidInsteadOfEmptyTuple": true,
4545
"UseLetInEveryBoundCaseVariable": true,

Examples/TheMovieDB/App.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SwiftUI
2+
3+
@main
4+
struct TheMovieDBApp: App {
5+
var body: some Scene {
6+
WindowGroup {
7+
TopRatedMoviesPage()
8+
}
9+
}
10+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
import Hooks
3+
4+
func useFetch<Response: Decodable>(
5+
_: Response.Type,
6+
path: String,
7+
parameters: [String: String] = [:],
8+
onReceiveResponse: ((Response) -> Void)? = nil
9+
) -> (status: AsyncStatus<Response, URLError>, fetch: () -> Void) {
10+
func makeURLRequest() -> URLRequest {
11+
let url = URL(string: "https://api.themoviedb.org/3").unsafelyUnwrapped.appendingPathComponent(path)
12+
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true).unsafelyUnwrapped
13+
14+
urlComponents.queryItems = [
15+
URLQueryItem(name: "api_key", value: "3de15b0402484d3d089399ea0b8d98f1")
16+
]
17+
18+
for (name, value) in parameters {
19+
urlComponents.queryItems?.append(URLQueryItem(name: name, value: value))
20+
}
21+
22+
var urlRequest = URLRequest(url: urlComponents.url.unsafelyUnwrapped)
23+
urlRequest.httpMethod = "GET"
24+
25+
return urlRequest
26+
}
27+
28+
let (status, subscribe) = usePublisherSubscribe {
29+
URLSession.shared.dataTaskPublisher(for: makeURLRequest())
30+
.map(\.data)
31+
.decode(type: Response.self, decoder: jsonDecoder)
32+
.mapError { $0 as? URLError ?? URLError(.cannotDecodeContentData) }
33+
.receive(on: DispatchQueue.main)
34+
.handleEvents(receiveOutput: onReceiveResponse)
35+
}
36+
37+
return (status: status, fetch: subscribe)
38+
}
39+
40+
private let jsonDecoder: JSONDecoder = {
41+
let formatter = DateFormatter()
42+
let decoder = JSONDecoder()
43+
formatter.dateFormat = "yyy-MM-dd"
44+
decoder.keyDecodingStrategy = .convertFromSnakeCase
45+
decoder.dateDecodingStrategy = .formatted(formatter)
46+
return decoder
47+
}()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Foundation
2+
import Hooks
3+
4+
func useFetchPage<Response: Decodable>(
5+
_: Response.Type,
6+
path: String,
7+
parameters: [String: String] = [:]
8+
) -> (status: AsyncStatus<[Response], URLError>, fetch: () -> Void) {
9+
let page = useRef(0)
10+
let results = useRef([Response]())
11+
12+
var parameters = parameters
13+
parameters["page"] = String(page.current + 1)
14+
15+
let (status, fetch) = useFetch(PagedResponse<Response>.self, path: path, parameters: parameters) { response in
16+
page.current = response.page
17+
results.current += response.results
18+
}
19+
20+
guard page.current == 0 else {
21+
return (status: .success(results.current), fetch: fetch)
22+
}
23+
24+
switch status {
25+
case .pending:
26+
return (status: .pending, fetch: fetch)
27+
28+
case .running:
29+
return (status: .running, fetch: fetch)
30+
31+
case .success:
32+
return (status: .success(results.current), fetch: fetch)
33+
34+
case .failure(let error):
35+
return (status: .failure(error), fetch: fetch)
36+
}
37+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Foundation
2+
import Hooks
3+
4+
func useFetchTopRatedMovies() -> (status: AsyncStatus<[Movie], URLError>, fetch: () -> Void) {
5+
useFetchPage(Movie.self, path: "movie/top_rated")
6+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Combine
2+
import Hooks
3+
import UIKit
4+
5+
func useNetworkImage(for path: String, size: NetworkImageSize) -> UIImage? {
6+
func makeURL() -> URL {
7+
URL(string: "https://image.tmdb.org/t/p/\(size.rawValue)").unsafelyUnwrapped.appendingPathComponent(path)
8+
}
9+
10+
let status = usePublisher(.preserved(by: [path, size.rawValue])) {
11+
URLSession.shared.dataTaskPublisher(for: makeURL())
12+
.map { data, _ in UIImage(data: data) }
13+
.catch { _ in Just(nil) }
14+
.receive(on: DispatchQueue.main)
15+
}
16+
17+
switch status {
18+
case .success(let image):
19+
return image
20+
21+
case .failure, .pending, .running:
22+
return nil
23+
}
24+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
struct Movie: Codable, Identifiable {
4+
let id: Int
5+
let title: String
6+
let overview: String
7+
let posterPath: String
8+
let backdropPath: String
9+
let voteAverage: Float
10+
let releaseDate: Date
11+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
enum NetworkImageSize: String {
2+
case original
3+
case small = "w154"
4+
case medium = "w500"
5+
case cast = "w185"
6+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
struct PagedResponse<T: Decodable>: Decodable {
2+
let page: Int
3+
let results: [T]
4+
}

Examples/TheMovieDB/Info.plist

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleDevelopmentRegion</key>
6+
<string>$(DEVELOPMENT_LANGUAGE)</string>
7+
<key>CFBundleExecutable</key>
8+
<string>$(EXECUTABLE_NAME)</string>
9+
<key>CFBundleIdentifier</key>
10+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11+
<key>CFBundleInfoDictionaryVersion</key>
12+
<string>6.0</string>
13+
<key>CFBundleName</key>
14+
<string>$(PRODUCT_NAME)</string>
15+
<key>CFBundlePackageType</key>
16+
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
17+
<key>CFBundleShortVersionString</key>
18+
<string>1.0</string>
19+
<key>CFBundleVersion</key>
20+
<string>1</string>
21+
<key>LSRequiresIPhoneOS</key>
22+
<true/>
23+
<key>UIApplicationSceneManifest</key>
24+
<dict>
25+
<key>UIApplicationSupportsMultipleScenes</key>
26+
<true/>
27+
</dict>
28+
<key>UIApplicationSupportsIndirectInputEvents</key>
29+
<true/>
30+
<key>UILaunchScreen</key>
31+
<dict/>
32+
<key>UIRequiredDeviceCapabilities</key>
33+
<array>
34+
<string>armv7</string>
35+
</array>
36+
<key>UISupportedInterfaceOrientations</key>
37+
<array>
38+
<string>UIInterfaceOrientationPortrait</string>
39+
<string>UIInterfaceOrientationLandscapeLeft</string>
40+
<string>UIInterfaceOrientationLandscapeRight</string>
41+
</array>
42+
<key>UISupportedInterfaceOrientations~ipad</key>
43+
<array>
44+
<string>UIInterfaceOrientationPortrait</string>
45+
<string>UIInterfaceOrientationPortraitUpsideDown</string>
46+
<string>UIInterfaceOrientationLandscapeLeft</string>
47+
<string>UIInterfaceOrientationLandscapeRight</string>
48+
</array>
49+
</dict>
50+
</plist>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import Hooks
2+
import SwiftUI
3+
4+
struct MovieDetailPage: HookView {
5+
let movie: Movie
6+
7+
var hookBody: some View {
8+
ScrollView {
9+
VStack(alignment: .leading, spacing: 24) {
10+
ZStack(alignment: .topLeading) {
11+
backdropImage
12+
closeButton
13+
14+
HStack(alignment: .top) {
15+
posterImage
16+
17+
Text(movie.title)
18+
.font(.title)
19+
.fontWeight(.heavy)
20+
.foregroundColor(Color(.label))
21+
.colorInvert()
22+
.padding(8)
23+
.shadow(radius: 4, y: 2)
24+
}
25+
.padding(.top, 70)
26+
.padding(.horizontal, 16)
27+
}
28+
29+
VStack(alignment: .leading, spacing: 16) {
30+
informationSection
31+
overviewSection
32+
}
33+
.padding(.horizontal, 16)
34+
.padding(.bottom, 24)
35+
}
36+
}
37+
.background(Color(.secondarySystemBackground).ignoresSafeArea())
38+
}
39+
40+
@ViewBuilder
41+
var closeButton: some View {
42+
let presentation = useEnvironment(\.presentationMode)
43+
44+
Button(action: { presentation.wrappedValue.dismiss() }) {
45+
ZStack {
46+
Color(.systemGray)
47+
.opacity(0.4)
48+
.clipShape(Circle())
49+
.frame(width: 34, height: 34)
50+
51+
Image(systemName: "xmark")
52+
.imageScale(.large)
53+
.font(Font.subheadline.bold())
54+
.foregroundColor(Color(.systemGray))
55+
}
56+
.padding(16)
57+
}
58+
}
59+
60+
@ViewBuilder
61+
var backdropImage: some View {
62+
let image = useNetworkImage(for: movie.backdropPath, size: .medium)
63+
64+
ZStack {
65+
Color(.systemGroupedBackground)
66+
67+
if let image = image {
68+
Image(uiImage: image)
69+
.resizable()
70+
.aspectRatio(contentMode: .fill)
71+
}
72+
73+
Color(.systemBackground).colorInvert().opacity(0.8)
74+
}
75+
.aspectRatio(CGSize(width: 5, height: 2), contentMode: .fit)
76+
}
77+
78+
@ViewBuilder
79+
var posterImage: some View {
80+
let image = useNetworkImage(for: movie.posterPath, size: .medium)
81+
82+
ZStack {
83+
Color(.systemGroupedBackground)
84+
85+
if let image = image {
86+
Image(uiImage: image)
87+
.resizable()
88+
.aspectRatio(contentMode: .fill)
89+
}
90+
}
91+
.frame(width: 150, height: 230)
92+
.cornerRadius(8)
93+
.shadow(radius: 4, y: 2)
94+
}
95+
96+
var informationSection: some View {
97+
HStack {
98+
Text(Int(movie.voteAverage * 10).description)
99+
.bold()
100+
.font(.title)
101+
.foregroundColor(Color(.systemGreen))
102+
+ Text("%")
103+
.bold()
104+
.font(.caption)
105+
.foregroundColor(Color(.systemGreen))
106+
107+
Text(DateFormatter.shared.string(from: movie.releaseDate))
108+
.font(.headline)
109+
.foregroundColor(Color(.secondaryLabel))
110+
}
111+
}
112+
113+
var overviewSection: some View {
114+
VStack(alignment: .leading, spacing: 8) {
115+
Text("Overview")
116+
.font(.title)
117+
.bold()
118+
119+
Text(movie.overview)
120+
.font(.system(size: 24))
121+
.foregroundColor(Color(.secondaryLabel))
122+
}
123+
}
124+
}
125+
126+
private extension DateFormatter {
127+
static let shared: DateFormatter = {
128+
let formatter = DateFormatter()
129+
formatter.dateStyle = .medium
130+
formatter.timeStyle = .none
131+
return formatter
132+
}()
133+
}

0 commit comments

Comments
 (0)