feat(recommendation): 추천 서브 배너와 연속 순환을 추가한다
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
struct SubBanner: Decodable, Hashable, Identifiable {
|
||||
enum Placement: String, Decodable {
|
||||
case homeRecommendation = "HOME_RECOMMENDATION"
|
||||
case contentRecommendation = "CONTENT_RECOMMENDATION"
|
||||
}
|
||||
|
||||
let id: Int
|
||||
let placement: Placement
|
||||
let imageUrl: String
|
||||
let webUrl: String?
|
||||
let androidDeepLink: String?
|
||||
let iosDeepLink: String?
|
||||
|
||||
var imageURL: URL? { Self.webURL(imageUrl) }
|
||||
var webURL: URL? { Self.webURL(webUrl) }
|
||||
var iosURL: URL? {
|
||||
guard let url = Self.absoluteURL(iosDeepLink),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
!["javascript", "data", "file", "content"].contains(scheme) else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
var isEligible: Bool {
|
||||
imageURL != nil && (iosURL != nil || webURL != nil)
|
||||
}
|
||||
|
||||
private static func absoluteURL(_ raw: String?) -> URL? {
|
||||
guard let raw, let url = URL(string: raw),
|
||||
url.scheme != nil, url.absoluteString == raw else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
private static func webURL(_ raw: String?) -> URL? {
|
||||
guard let url = absoluteURL(raw),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
let host = url.host, !host.isEmpty else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
|
||||
import Kingfisher
|
||||
|
||||
struct SubBannerCarousel: View {
|
||||
let items: [SubBanner]
|
||||
let onTap: (SubBanner) -> Void
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var displayIndex = 1
|
||||
@State private var itemsRevision = 0
|
||||
@State private var containerWidth: CGFloat = 0
|
||||
@State private var imageRatios: [String: CGFloat] = [:]
|
||||
@State private var lastKnownRatio: CGFloat?
|
||||
@GestureState private var isSwiping = false
|
||||
@GestureState private var dragOffset: CGFloat = 0
|
||||
|
||||
private static let autoScrollNanoseconds: UInt64 = 5_000_000_000
|
||||
private static let transitionDuration: TimeInterval = 0.35
|
||||
|
||||
private var safeDisplayIndex: Int {
|
||||
items.count > 1 ? min(max(displayIndex, 0), items.count + 1) : 0
|
||||
}
|
||||
|
||||
private var safeIndex: Int {
|
||||
items.count > 1 ? (safeDisplayIndex + items.count - 1) % items.count : 0
|
||||
}
|
||||
|
||||
private var pages: [(id: String, item: SubBanner)] {
|
||||
let originals = items.map { (id: "item-\($0.id)", item: $0) }
|
||||
guard items.count > 1, let first = items.first, let last = items.last else { return originals }
|
||||
return [(id: "leading-\(last.id)", item: last)] + originals + [(id: "trailing-\(first.id)", item: first)]
|
||||
}
|
||||
|
||||
private var currentRatio: CGFloat? {
|
||||
guard !items.isEmpty else { return nil }
|
||||
return imageRatios[items[safeIndex].imageUrl]
|
||||
}
|
||||
|
||||
private var height: CGFloat {
|
||||
guard !items.isEmpty, let ratio = currentRatio ?? lastKnownRatio else { return 0 }
|
||||
return containerWidth / ratio
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(pages, id: \.id) { page in
|
||||
Button {
|
||||
onTap(page.item)
|
||||
} label: {
|
||||
KFImage(page.item.imageURL)
|
||||
.placeholder { Color.gray800 }
|
||||
.onSuccess { result in
|
||||
let size = result.image.size
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
imageRatios[page.item.imageUrl] = size.width / size.height
|
||||
}
|
||||
.startLoadingBeforeViewAppear()
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: geometry.size.width, height: height)
|
||||
.background(Color.gray800)
|
||||
}
|
||||
.buttonStyle(SubBannerTapStyle(drag: dragGesture(width: geometry.size.width)))
|
||||
.accessibilityLabel(Text("\(safeIndex + 1) / \(items.count)"))
|
||||
.accessibilityHidden(pages[safeDisplayIndex].id != page.id)
|
||||
}
|
||||
}
|
||||
.offset(x: -CGFloat(safeDisplayIndex) * geometry.size.width + dragOffset)
|
||||
.animation(.easeOut(duration: Self.transitionDuration), value: displayIndex)
|
||||
.frame(width: geometry.size.width, height: height, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.clipped()
|
||||
.onAppear { containerWidth = geometry.size.width }
|
||||
.onChange(of: geometry.size.width) { containerWidth = $0 }
|
||||
}
|
||||
.frame(height: height)
|
||||
.onChange(of: currentRatio) { ratio in
|
||||
if let ratio { lastKnownRatio = ratio }
|
||||
}
|
||||
.onChange(of: items) { _ in
|
||||
itemsRevision += 1
|
||||
normalize(to: items.count > 1 ? 1 : 0)
|
||||
if items.isEmpty { lastKnownRatio = nil }
|
||||
}
|
||||
.onDisappear {
|
||||
itemsRevision += 1
|
||||
normalize(to: items.count > 1 ? safeIndex + 1 : 0)
|
||||
}
|
||||
.task(id: scenePhase == .active && items.count > 1 ? items : []) {
|
||||
guard scenePhase == .active, items.count > 1 else { return }
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: Self.autoScrollNanoseconds)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
if !isSwiping { move(by: 1) }
|
||||
}
|
||||
}
|
||||
.accessibilityAdjustableAction { direction in
|
||||
switch direction {
|
||||
case .increment: move(by: 1)
|
||||
case .decrement: move(by: -1)
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dragGesture(width: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: SodaSpacing.s12, coordinateSpace: .global)
|
||||
.updating($dragOffset) { value, offset, _ in
|
||||
if items.count > 1, abs(value.translation.width) > abs(value.translation.height) {
|
||||
offset = value.translation.width
|
||||
}
|
||||
}
|
||||
.updating($isSwiping) { _, swiping, _ in swiping = true }
|
||||
.onEnded { value in
|
||||
guard abs(value.translation.width) > abs(value.translation.height) else { return }
|
||||
if value.translation.width < -width * 0.2 {
|
||||
move(by: 1)
|
||||
} else if value.translation.width > width * 0.2 {
|
||||
move(by: -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func move(by step: Int) {
|
||||
guard items.count > 1, (1...items.count).contains(displayIndex) else { return }
|
||||
let next = displayIndex + step
|
||||
displayIndex = next
|
||||
guard next == 0 || next == items.count + 1 else { return }
|
||||
let revision = itemsRevision
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.transitionDuration) {
|
||||
guard itemsRevision == revision, displayIndex == next else { return }
|
||||
normalize(to: next == 0 ? items.count : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func normalize(to index: Int) {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) { displayIndex = index }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SubBannerTapStyle<Drag: Gesture>: PrimitiveButtonStyle {
|
||||
let drag: Drag
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(drag.exclusively(before: TapGesture().onEnded { configuration.trigger() }))
|
||||
.accessibilityAction { configuration.trigger() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
enum SubBannerLinkOpener {
|
||||
static func open(_ banner: SubBanner, using opener: @escaping (URL, @escaping (Bool) -> Void) -> Void) {
|
||||
let webURL = banner.webURL
|
||||
if let iosURL = banner.iosURL {
|
||||
opener(iosURL) { opened in
|
||||
if !opened, let webURL {
|
||||
opener(webURL) { _ in }
|
||||
}
|
||||
}
|
||||
} else if let webURL {
|
||||
opener(webURL) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
static func open(_ banner: SubBanner) {
|
||||
open(banner) { url, completion in
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: completion)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -32,6 +32,12 @@ struct MainContentRecommendationView: View {
|
||||
onTapBanner: onTapBanner
|
||||
)
|
||||
|
||||
if !recommendations.eligibleSubBanners.isEmpty {
|
||||
SubBannerCarousel(items: recommendations.eligibleSubBanners) {
|
||||
SubBannerLinkOpener.open($0)
|
||||
}
|
||||
}
|
||||
|
||||
MainContentVoiceOnOnlySection(
|
||||
title: I18n.MainContentRecommendation.sodaLiveOnlySectionTitle,
|
||||
items: recommendations.originalSeries,
|
||||
|
||||
+8
@@ -43,6 +43,14 @@ final class MainContentRecommendationViewModel: ObservableObject {
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
guard response.statusCode == 200 else {
|
||||
self.errorMessage = I18n.MainContentRecommendation.loadFailedMessage
|
||||
self.isShowPopup = true
|
||||
self.isLoading = false
|
||||
self.hasLoaded = true
|
||||
return
|
||||
}
|
||||
|
||||
let responseData = response.data
|
||||
|
||||
do {
|
||||
|
||||
+6
@@ -2,6 +2,7 @@ import Foundation
|
||||
|
||||
struct AudioRecommendationsResponse: Decodable {
|
||||
let banners: [AudioBannerResponse]
|
||||
let subBanners: [SubBanner]
|
||||
let originalSeries: [OriginalSeriesResponse]
|
||||
let latestAudios: [AudioCardResponse]
|
||||
let newAndHotAudios: [AudioCardResponse]
|
||||
@@ -10,8 +11,13 @@ struct AudioRecommendationsResponse: Decodable {
|
||||
let mostCommentedAudios: [CommentedAudioResponse]
|
||||
let recommendedAudios: [AudioCardResponse]
|
||||
|
||||
var eligibleSubBanners: [SubBanner] {
|
||||
subBanners.filter { $0.placement == .contentRecommendation && $0.isEligible }
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
banners.isEmpty &&
|
||||
eligibleSubBanners.isEmpty &&
|
||||
originalSeries.isEmpty &&
|
||||
latestAudios.isEmpty &&
|
||||
newAndHotAudios.isEmpty &&
|
||||
|
||||
+3
-1
@@ -32,6 +32,8 @@ extension MainContentRecommendationApi: TargetType {
|
||||
}
|
||||
|
||||
var headers: [String: String]? {
|
||||
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||
let token = UserDefaults.string(forKey: .token)
|
||||
guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||
return ["Authorization": "Bearer \(token)"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ struct MainHomeRecommendationView: View {
|
||||
onTapCommunityPost: onTapCommunityPost
|
||||
)
|
||||
|
||||
if let items = viewModel.recommendations?.eligibleSubBanners, !items.isEmpty {
|
||||
SubBannerCarousel(items: items) { SubBannerLinkOpener.open($0) }
|
||||
}
|
||||
|
||||
MainHomeRecentDebutCreatorSection(
|
||||
items: viewModel.recommendations?.recentDebutCreators ?? [],
|
||||
onTapCreator: onTapCreator
|
||||
|
||||
@@ -33,6 +33,13 @@ final class MainHomeRecommendationViewModel: ObservableObject {
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
|
||||
guard response.statusCode == 200 else {
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
self.isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let responseData = response.data
|
||||
|
||||
do {
|
||||
|
||||
+5
@@ -3,12 +3,17 @@ import Foundation
|
||||
struct HomeRecommendationResponse: Decodable {
|
||||
let lives: [HomeLiveItem]
|
||||
let banners: [RecommendationBannerResponse]
|
||||
let subBanners: [SubBanner]
|
||||
let recentlyActiveCreators: [HomeActiveCreatorItem]
|
||||
let recentDebutCreators: [HomeCreatorItem]
|
||||
let aiCharacters: [HomeAiCharacterItem]
|
||||
let genreCreators: [HomeGenreCreatorGroupItem]
|
||||
let cheerCreators: [HomeCreatorItem]
|
||||
let popularCommunityPosts: [HomePopularCommunityPostItem]
|
||||
|
||||
var eligibleSubBanners: [SubBanner] {
|
||||
subBanners.filter { $0.placement == .homeRecommendation && $0.isEligible }
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeLiveItem: Decodable, Hashable {
|
||||
|
||||
+6
-1
@@ -42,6 +42,11 @@ extension MainHomeRecommendationApi: TargetType {
|
||||
}
|
||||
|
||||
var headers: [String: String]? {
|
||||
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||
let token = UserDefaults.string(forKey: .token)
|
||||
if case .getRecommendations = self,
|
||||
token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
return ["Authorization": "Bearer \(token)"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user