Skip to content

랭킹 뷰 기능 개발 #33

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 14, 2025
95 changes: 47 additions & 48 deletions DDanDDan/Presenter/Components/CustomScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,59 +8,58 @@
import SwiftUI

struct CustomScrollView<Content: View>: View {
@Binding private var contentOffset: CGPoint
@Binding private var reachToBottom: Bool

private let frameHeight: CGFloat
@State private var contentHeight = CGFloat.zero
@Namespace private var coordinateSpaceName: Namespace.ID
@ViewBuilder private var content: (ScrollViewProxy) -> Content

init(
frameHeight: CGFloat,
contentOffset: Binding<CGPoint>,
reachToBottom: Binding<Bool>,
@ViewBuilder content: @escaping (ScrollViewProxy) -> Content
) {
self.frameHeight = frameHeight
_contentOffset = contentOffset
_reachToBottom = reachToBottom
self.content = content
}

let content: () -> Content
let onBottomReached: () -> Void

@State private var isBottomReached = false
@State private var hasScrolled = false

var body: some View {
ScrollView(.vertical, showsIndicators: true) {
ScrollViewReader { scrollViewProxy in
content(scrollViewProxy)
.background {
GeometryReader { geometryProxy in
Color.clear
.onAppear {
let contentHeight = geometryProxy.size.height
self.contentHeight = contentHeight
}
.preference(
key: ScrollOffsetPreferenceKey.self,
value: CGPoint(
x: -geometryProxy.frame(in: .named(coordinateSpaceName)).minX,
y: -geometryProxy.frame(in: .named(coordinateSpaceName)).minY
)
)
}
}
ScrollView {
VStack(spacing: 0) {
content()

Rectangle()
.fill(Color.clear)
.frame(height: 1)
.modifier(ScrollOffsetReader())
}
}
.coordinateSpace(name: coordinateSpaceName)
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
guard contentHeight != 0 else { return }
let currentScrollOffset = value.y + frameHeight
reachToBottom = contentHeight <= currentScrollOffset
contentOffset = value
.scrollIndicators(.hidden)
.coordinateSpace(name: "scroll")
.onPreferenceChange(OffsetPreferenceKey.self) { maxY in
let screenHeight = UIScreen.main.bounds.height

if maxY < screenHeight + 50 && !isBottomReached {
isBottomReached = true
onBottomReached()
} else if maxY > screenHeight + 100 {
isBottomReached = false
}
}
}
}

private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint { .zero }
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0

static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}

struct ScrollOffsetReader: ViewModifier {
func body(content: Content) -> some View {
content
.background(
GeometryReader { geo in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: geo.frame(in: .named("scroll")).maxY
)
}
.frame(height: 1)
)
}
}
5 changes: 2 additions & 3 deletions DDanDDan/Presenter/Components/CustomTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,11 @@ struct CustomTabView: View {
}
.onAppear {
viewStore.send(.updateBarPosition(CGFloat(viewStore.selection.rawValue)))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
viewStore.send(.activateBar)
}
viewStore.send(.activateBar)
}
}
.frame(height: 56)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 20)

if let view = views[viewStore.selection] {
Expand Down
3 changes: 1 addition & 2 deletions DDanDDan/Presenter/Components/ToastView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ struct ToastView: View {

var body: some View {
ZStack {
Color(.toastElevationLevel03)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
BackgroundBlurView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
HStack {
Expand Down
159 changes: 86 additions & 73 deletions DDanDDan/Presenter/Rank/RankContentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,72 +12,45 @@ import ComposableArchitecture
struct RankContentsView: View {
@State private var textWidth: CGFloat = 0
@State private var showTooltip = false
@State private var contentOffset = CGPoint.zero
@State private var reachToBottom = false
var tabType: Tab

let store: StoreOf<RankFeature>

@State private var scrollToIndex: Int? = nil

var body: some View {
let viewStore = ViewStore(store, observe: { $0 })

WithPerceptionTracking {
ZStack {
ZStack(alignment: .bottom) {
Color(.backgroundBlack)
WithPerceptionTracking {
ScrollView {
VStack(alignment: .leading) {
Text(setDateCirteria())
.font(.body2_regular14)
.foregroundStyle(.textBodyTeritary)
.padding(.leading, 20)
VStack(alignment: .leading) {
HStack(alignment: .bottom){
Text(tabType.GuideTitle)
.font(.neoDunggeunmo24)
.foregroundStyle(.textButtonAlternative)
.background(
WithPerceptionTracking{
GeometryReader { geo in
Color.clear
.onAppear {
textWidth = geo.size.width
}
}
})
.padding(.leading, 20)
Button(
action: {
withAnimation {
showTooltip.toggle()
}
},
label: {
Image(.iconInfomation)
.frame(width: 20, height: 20)
}
)
Color(.backgroundBlack)
WithPerceptionTracking {
ZStack(alignment: .bottom) {
WithPerceptionTracking {

ScrollViewReader { proxy in
CustomScrollView {
VStack(alignment: .leading) {
headerView
rankContainerView
}
ZStack {
if showTooltip {
ToolKitView(textString: tabType.toolKitMessage)
.offset(x: textWidth / 2)
}
} onBottomReached: {
guard let totalRankCount = store.totalRankCount else { return }
store.send(.setShowToast(true, totalRankCount < 100 ? "랭킹이 아직 \(totalRankCount)등까지 밖에 없어요" : "순위는 100위까지만 노출해요"))
}
.onReceive(viewStore.publisher.focusedMyRankIndex.compactMap { $0 }) { index in
withAnimation {
proxy.scrollTo(index, anchor: .top)
}
.frame(height: 32)
}
rankContainerView
}
.frame(maxWidth: .infinity)
}
.padding(.top, 24)
.frame(maxWidth: .infinity)
.scrollIndicators(.hidden)
myRankView
TransparentOverlayView(isPresented: store.state.showToast, isDimView: false) {
VStack {
ToastView(message: store.state.toastMessage, toastType: .info)
}
.position(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height - 250.adjustedHeight)
.position(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height - 320.adjustedHeight)
}
}
}
Expand All @@ -91,8 +64,11 @@ struct RankContentsView: View {
.background(Color.backgroundBlack)
}
}

}
.onReceive(viewStore.publisher.focusedMyRankIndex) { index in
scrollToIndex = index
}

}
}

Expand All @@ -108,6 +84,51 @@ struct RankContentsView: View {

extension RankContentsView {

var headerView: some View {
VStack(alignment: .leading) {
Text(setDateCirteria())
.font(.body2_regular14)
.foregroundStyle(.textBodyTeritary)
.padding(.leading, 20)
.padding(.top, 24)
VStack(alignment: .leading) {
HStack(alignment: .bottom){
Text(tabType.GuideTitle)
.font(.neoDunggeunmo24)
.foregroundStyle(.textButtonAlternative)
.background(
WithPerceptionTracking{
GeometryReader { geo in
Color.clear
.onAppear {
textWidth = geo.size.width
}
}
})
.padding(.leading, 20)
Button(
action: {
withAnimation {
showTooltip.toggle()
}
},
label: {
Image(.iconInfomation)
.frame(width: 20, height: 20)
}
)
}
ZStack {
if showTooltip {
ToolKitView(textString: tabType.toolKitMessage)
.offset(x: textWidth / 2)
}
}
.frame(height: 32)
}
}
}

var rankContainerView: some View {
WithPerceptionTracking {
VStack {
Expand Down Expand Up @@ -145,27 +166,15 @@ extension RankContentsView {

var rankListView: some View {
let rankers = (tabType == .kcal ? store.kcalRanking?.ranking.dropFirst(3) : store.goalRanking?.ranking.dropFirst(3)) ?? []
let totalCount = store.kcalRanking?.ranking.count ?? 0

return WithPerceptionTracking {
CustomScrollView(
frameHeight: CGFloat(48 * rankers.count),
contentOffset: $contentOffset,
reachToBottom: $reachToBottom
) {_ in
LazyVStack(spacing: 0) {
ForEach(rankers.indices, id: \.self) { index in
rankListItemView(rank: rankers[index], index: index)
}
}
.padding(.bottom, 100.adjustedHeight)
}
.onChange(of: reachToBottom) { isReached in
print(contentOffset)
if isReached {
store.send(.setShowToast(isReached, totalCount < 100 ? "랭킹이 아직 \(totalCount)등까지 밖에 없어요" : "랭킹은 100등까지만 노출해요"))
LazyVStack(spacing: 0) {
ForEach(rankers.indices, id: \.self) { index in
rankListItemView(rank: rankers[index], index: index)
.id(index+1)
}
}
.padding(.bottom, 100.adjustedHeight)
}
}

Expand All @@ -177,6 +186,7 @@ extension RankContentsView {
.foregroundStyle(.textButtonAlternative)
.font(.neoDunggeunmo16)
.frame(width: 24, alignment: .leading)
.padding(.trailing, 12)

ZStack {
Circle()
Expand Down Expand Up @@ -207,7 +217,7 @@ extension RankContentsView {

Spacer()

Text(tabType == .kcal ? "\(rank.totalCalories)" : "\(rank.totalSucceededDays)")
Text(tabType == .kcal ? "\(rank.totalCalories)" : "+\(rank.totalSucceededDays)")
.foregroundStyle(.textButtonAlternative)
.font(.body1_bold16)
.padding(.trailing, 2)
Expand All @@ -227,9 +237,9 @@ extension RankContentsView {
var myRankView: some View {
ZStack(alignment: .center) {
Rectangle()
.frame(maxWidth: .infinity, maxHeight: 100.adjustedHeight)
.particalCornerRadius(16, corners: .topLeft)
.particalCornerRadius(16, corners: .topRight)
.frame(maxHeight: 100.adjustedHeight)
.particalCornerRadius(16.adjustedHeight, corners: .topLeft)
.particalCornerRadius(16.adjustedHeight, corners: .topRight)
.foregroundStyle(.borderGray)
HStack(alignment: .center) {
Text(String((self.tabType == .kcal ? store.kcalRanking?.myRanking.rank : store.goalRanking?.myRanking.rank) ?? 0))
Expand Down Expand Up @@ -258,7 +268,7 @@ extension RankContentsView {
.background(Color.textHeadlinePrimary)
.clipShape(Circle())
Spacer()
Text(String((self.tabType == .kcal ? store.kcalRanking?.myRanking.totalCalories : store.goalRanking?.myRanking.totalSucceededDays) ?? 0))
Text(self.tabType == .kcal ? String(store.kcalRanking?.myRanking.totalCalories ?? 0) : "+" + String(store.goalRanking?.myRanking.totalSucceededDays ?? 0))
.font(.body1_bold16)
.foregroundStyle(.textButtonAlternative)
Text(self.tabType == .kcal ? "kcal" : "일")
Expand All @@ -270,6 +280,9 @@ extension RankContentsView {
.padding(.top, 20)
.padding(.bottom, 32)
}
.onTapGesture {
store.send(.focusMyRank(index: (self.tabType == .kcal ? store.kcalRanking?.myRanking.rank : store.goalRanking?.myRanking.rank) ?? 0))
}
}
}

Expand All @@ -291,7 +304,7 @@ struct RankCard: View {
.frame(width: 82)
.lineLimit(1)
.lineSpacing(24)
Text(tabType == .kcal ? "\(ranking.totalCalories)kcal" : "\(ranking.totalSucceededDays)일")
Text(tabType == .kcal ? "\(ranking.totalCalories)kcal" : "+\(ranking.totalSucceededDays)일")
.font(.body1_bold16)
.foregroundStyle(Color.textButtonAlternative)
}
Expand Down
Loading