Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ACON-iOS/ACON-iOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
1558BA1A2D318FFC00ECDEF8 /* ACTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1558BA192D318FFC00ECDEF8 /* ACTabBarController.swift */; };
1558BADB2D31AAF900ECDEF8 /* ACTabBarItemType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1558BADA2D31AAF900ECDEF8 /* ACTabBarItemType.swift */; };
1558BADE2D31AB6C00ECDEF8 /* SpotListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1558BADD2D31AB6C00ECDEF8 /* SpotListViewController.swift */; };
155BC2922DF09EB200E1744E /* ShadowColorCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 155BC2912DF09EB200E1744E /* ShadowColorCache.swift */; };
156AA7242D6504F1005B8DCE /* GetVerifiedAreaListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156AA7232D6504F1005B8DCE /* GetVerifiedAreaListResponse.swift */; };
156AA72A2D6510E1005B8DCE /* GetNicknameValidityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156AA7292D6510E1005B8DCE /* GetNicknameValidityRequest.swift */; };
156AE6792DE0F1D300AE800D /* NoMatchingSpotListItemSizeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 156AE6782DE0F1D300AE800D /* NoMatchingSpotListItemSizeType.swift */; };
Expand Down Expand Up @@ -299,6 +300,7 @@
1558BA192D318FFC00ECDEF8 /* ACTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACTabBarController.swift; sourceTree = "<group>"; };
1558BADA2D31AAF900ECDEF8 /* ACTabBarItemType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACTabBarItemType.swift; sourceTree = "<group>"; };
1558BADD2D31AB6C00ECDEF8 /* SpotListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotListViewController.swift; sourceTree = "<group>"; };
155BC2912DF09EB200E1744E /* ShadowColorCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowColorCache.swift; sourceTree = "<group>"; };
156AA7232D6504F1005B8DCE /* GetVerifiedAreaListResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetVerifiedAreaListResponse.swift; sourceTree = "<group>"; };
156AA7292D6510E1005B8DCE /* GetNicknameValidityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetNicknameValidityRequest.swift; sourceTree = "<group>"; };
156AE6782DE0F1D300AE800D /* NoMatchingSpotListItemSizeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoMatchingSpotListItemSizeType.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1245,6 +1247,7 @@
74054ECD2D32549800D1CDE4 /* ACLocationManager.swift */,
74054EC92D32533800D1CDE4 /* MultitaskDelegate.swift */,
748D6F812D2BCDB0007690B4 /* ScreenUtils.swift */,
155BC2912DF09EB200E1744E /* ShadowColorCache.swift */,
748D6F7F2D2BCD8F007690B4 /* ObservablePattern.swift */,
748D6F7B2D2BCCFF007690B4 /* Enums */,
);
Expand Down Expand Up @@ -1863,6 +1866,7 @@
748D6F822D2BCDB3007690B4 /* ScreenUtils.swift in Sources */,
151AB6C52DD3874100D01DE8 /* SpotDetailViewController.swift in Sources */,
741E68AC2D3D6DA800DF99EF /* HeaderType.swift in Sources */,
155BC2922DF09EB200E1744E /* ShadowColorCache.swift in Sources */,
151BD9532D6332A5005E657F /* VerifiedAreasEditView.swift in Sources */,
74A13D642DCBBAB9007FFFC3 /* OnboardingView.swift in Sources */,
745C7E122D35A62B0074DBDB /* UploadModel.swift in Sources */,
Expand Down
85 changes: 85 additions & 0 deletions ACON-iOS/ACON-iOS/Global/Extensions/UIImage+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,88 @@ extension UIImage {
}

}


// MARK: - 색상 추출

extension UIImage {

/// 이미지의 가장자리에서 가장 많이 사용되는 색상을 추출합니다.
func mostFrequentBorderColor(
width: CGFloat = SpotListItemSizeType.itemMaxWidth.value,
height: CGFloat = SpotListItemSizeType.itemMaxHeight.value,
scaleFactor: CGFloat = 0.3,
sampleDensity: Int = 10
) -> UIColor? {
guard let cgImage = self.cgImage else { return nil }

let scaledWidth = Int(width * scaleFactor)
let scaledHeight = Int(height * scaleFactor)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bytesPerPixel = 4
let bytesPerRow = bytesPerPixel * scaledWidth
let bitsPerComponent = 8

var pixelData = [UInt8](repeating: 0, count: scaledWidth * scaledHeight * bytesPerPixel)

guard let context = CGContext(
data: &pixelData,
width: scaledWidth,
height: scaledHeight,
bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }

context.draw(cgImage, in: CGRect(x: 0, y: 0, width: scaledWidth, height: scaledHeight))

var colorCounts: [Int: Int] = [:]

let stepX = max(1, scaledWidth / sampleDensity)
for x in stride(from: 0, to: scaledWidth, by: stepX) {
addPixelColor(x: x, y: 0, width: scaledWidth, height: scaledHeight, pixelData: pixelData, colorCounts: &colorCounts)
addPixelColor(x: x, y: scaledHeight - 1, width: scaledWidth, height: scaledHeight, pixelData: pixelData, colorCounts: &colorCounts)
}

let stepY = max(1, scaledHeight / sampleDensity)
for y in stride(from: 0, to: scaledHeight, by: stepY) {
addPixelColor(x: 0, y: y, width: scaledWidth, height: scaledHeight, pixelData: pixelData, colorCounts: &colorCounts)
addPixelColor(x: scaledWidth - 1, y: y, width: scaledWidth, height: scaledHeight, pixelData: pixelData, colorCounts: &colorCounts)
}

return getMostFrequentColor(from: colorCounts)
}

private func addPixelColor(
x: Int,
y: Int,
width: Int,
height: Int,
pixelData: [UInt8],
colorCounts: inout [Int: Int]
) {
guard x >= 0, x < width, y >= 0, y < height else { return }

let index = (y * width + x) * 4
guard index + 3 < pixelData.count else { return }

let r = pixelData[index] >> 3
let g = pixelData[index + 1] >> 3
let b = pixelData[index + 2] >> 3

let colorKey = (Int(r) << 10) | (Int(g) << 5) | Int(b)
colorCounts[colorKey, default: 0] += 1
}

private func getMostFrequentColor(from colorCounts: [Int: Int]) -> UIColor? {
guard let (colorKey, _) = colorCounts.max(by: { $0.value < $1.value }) else { return nil }

let r = CGFloat((colorKey >> 10) & 0x1F) / 31.0
let g = CGFloat((colorKey >> 5) & 0x1F) / 31.0
let b = CGFloat(colorKey & 0x1F) / 31.0

return UIColor(red: r, green: g, blue: b, alpha: 1.0)
}

}
16 changes: 8 additions & 8 deletions ACON-iOS/ACON-iOS/Global/Utils/Enums/GlassmorphismType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,28 @@ enum GlassmorphismType {
switch self {
case .buttonGlassDefault, .buttonGlassPressed, .buttonGlassSelected, .buttonGlassDisabled, .toastGlass, .gradientGlass, .noImageErrorGlass:
return 0.2
case .bottomSheetGlass, .alertGlass, .actionSheetGlass, .needLoginErrorGlass:
case .alertGlass, .actionSheetGlass, .needLoginErrorGlass:
return 0.4
case .bottomSheetGlass:
return 0.6
case .backgroundGlass:
return 1
}
}

var blurEffectStyle: UIBlurEffect.Style {
switch self {
case .buttonGlassDisabled:
return .systemUltraThinMaterialLight
case .buttonGlassDefault, .actionSheetGlass, .noImageErrorGlass:
return .systemThinMaterialLight
case .buttonGlassPressed:
return .systemThickMaterialLight
case .buttonGlassSelected:
return .systemMaterialLight
case .buttonGlassDisabled:
return .systemUltraThinMaterialLight
case .toastGlass, .needLoginErrorGlass, .alertGlass:
return .systemThinMaterialDark
case .buttonGlassPressed:
return .systemThickMaterialLight
case .gradientGlass:
return .systemUltraThinMaterialDark
case .backgroundGlass, .bottomSheetGlass:
case .toastGlass, .needLoginErrorGlass, .alertGlass, .backgroundGlass, .bottomSheetGlass:
return .systemThinMaterialDark
}
Comment on lines 18 to 44
Copy link
Contributor

@cirtuare cirtuare Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐿️
전부 디자인이랑 합의된 사항들 맞을까요!!

Copy link
Collaborator Author

@yurim830 yurim830 Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 맞습니다! 특별히 변경한 건 없고,
blurIntensity 부분에서 bottomSheetGlass만 0.4 -> 0.6으로 변경했습니다.
blurEffectStyle 부분은 light -> dark, thin -> think 순으로 정렬만 했어요!

}
Expand Down
31 changes: 31 additions & 0 deletions ACON-iOS/ACON-iOS/Global/Utils/ShadowColorCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// ShadowColorCache.swift
// ACON-iOS
//
// Created by 김유림 on 6/5/25.
//

import UIKit

class ShadowColorCache {

// MARK: - Properties

static let shared = ShadowColorCache()

static let noImageKey: String = "noImage"

private let cache = NSCache<NSString, UIColor>()


// MARK: - Methods

func color(for key: String) -> UIColor? {
return cache.object(forKey: key as NSString)
}

func setColor(_ color: UIColor, for key: String) {
cache.setObject(color, forKey: key as NSString)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ class SpotListCollectionViewCell: BaseCollectionViewCell {

// MARK: - UI Properties

private var currentImageURL: String? // NOTE: 셀 재사용 이슈 방지 목적

private let bgImage = UIImageView()
private let dimImage = UIImageView()
private let bgImageShadowView = UIView()

private let noImageContentView = SpotNoImageContentView(.iconAndDescription)
private let loginlockOverlayView = LoginLockOverlayView()
Expand All @@ -32,21 +35,27 @@ class SpotListCollectionViewCell: BaseCollectionViewCell {
override func setHierarchy() {
super.setHierarchy()

self.addSubviews(bgImage,
self.addSubviews(bgImageShadowView,
dimImage,
noImageContentView,
titleLabel,
acornCountButton,
tagStackView,
findCourseButton,
loginlockOverlayView)

bgImageShadowView.addSubview(bgImage)
}

override func setLayout() {
super.setLayout()

let edge = ScreenUtils.widthRatio * 20

bgImageShadowView.snp.makeConstraints {
$0.edges.equalToSuperview()
}

bgImage.snp.makeConstraints {
$0.edges.equalToSuperview()
}
Expand Down Expand Up @@ -89,6 +98,10 @@ class SpotListCollectionViewCell: BaseCollectionViewCell {
override func setStyle() {
backgroundColor = .clear

bgImageShadowView.do {
$0.clipsToBounds = false
}

bgImage.do {
$0.clipsToBounds = true
$0.contentMode = .scaleAspectFill
Expand Down Expand Up @@ -128,8 +141,14 @@ class SpotListCollectionViewCell: BaseCollectionViewCell {

override func prepareForReuse() {
super.prepareForReuse()

findCourseButton.refreshBlurEffect()

currentImageURL = nil
bgImage.kf.cancelDownloadTask()
bgImage.image = nil

bgImageShadowView.layer.shadowColor = UIColor.clear.cgColor
}

}
Expand All @@ -140,42 +159,108 @@ class SpotListCollectionViewCell: BaseCollectionViewCell {
extension SpotListCollectionViewCell: SpotListCellConfigurable {

func bind(spot: SpotModel) {
let imageURL = spot.imageURL ?? ""
currentImageURL = imageURL

setBgImageAndShadow(from: imageURL)

titleLabel.setLabel(text: spot.name, style: .t4SB)

setAcornCountButton(to: spot.acornCount)

tagStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
spot.tagList.forEach { tag in
tagStackView.addArrangedSubview(SpotTagButton(tag))
}

setFindCourseButton(to: spot.eta)
}

func overlayLoginLock(_ show: Bool) {
loginlockOverlayView.isHidden = !show
}

}


// MARK: - Helper

private extension SpotListCollectionViewCell {

func setBgImageAndShadow(from imageURL: String) {
bgImage.kf.setImage(
with: URL(string: spot.imageURL ?? ""),
with: URL(string: imageURL),
placeholder: UIImage.imgSkeletonBg,
options: [.transition(.none), .cacheOriginalImage],
completionHandler: { result in
options: [
.transition(.none),
.cacheOriginalImage,
.scaleFactor(UIScreen.main.scale)
],
completionHandler: { [weak self] result in
guard let self = self else { return }

switch result {
case .success:
case .success(let value):
self.noImageContentView.isHidden = true
self.dimImage.isHidden = false
self.extractAndApplyShadowColor(from: value.image, for: imageURL)

case .failure:
self.bgImage.image = .imgSpotNoImageBackground
self.noImageContentView.isHidden = false
self.dimImage.isHidden = true
self.extractAndApplyShadowColor(from: .imgSpotNoImageBackground, for: ShadowColorCache.noImageKey)
}
}
)
}

titleLabel.setLabel(text: spot.name, style: .t4SB)

let acornCount: Int = spot.acornCount
func setAcornCountButton(to acornCount: Int) {
let acornString: String = acornCount > 9999 ? "+9999" : String(acornCount)
acornCountButton.setAttributedTitle(text: String(acornString), style: .b1R)
}

tagStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
spot.tagList.forEach { tag in
tagStackView.addArrangedSubview(SpotTagButton(tag))
}

func setFindCourseButton(to eta: Int) {
let walk: String = StringLiterals.SpotList.walk
let findCourse: String = StringLiterals.SpotList.minuteFindCourse
let courseTitle: String = walk + String(spot.eta) + findCourse
let courseTitle: String = walk + String(eta) + findCourse
findCourseButton.setAttributedTitle(text: courseTitle, style: .b1SB)
}

func overlayLoginLock(_ show: Bool) {
loginlockOverlayView.isHidden = !show
func extractAndApplyShadowColor(from image: UIImage, for key: String) {
// NOTE: 캐시 확인 및 적용
if let cachedColor = ShadowColorCache.shared.color(for: key) {
applyShadowColor(cachedColor)
return
}

// NOTE: 색상 추출 및 적용
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let dominantColor = image.mostFrequentBorderColor()

if let color = dominantColor {
ShadowColorCache.shared.setColor(color, for: key)

DispatchQueue.main.async {
guard self?.shouldApplyShadow(for: key) == true else { return }
self?.applyShadowColor(color)
}
}
}
}

func applyShadowColor(_ color: UIColor) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
bgImageShadowView.layer.shadowOpacity = 0.7
bgImageShadowView.layer.shadowRadius = 60
bgImageShadowView.layer.shadowOffset = CGSize(width: 0, height: 0)
bgImageShadowView.layer.shadowColor = color.cgColor
}
}

func shouldApplyShadow(for key: String) -> Bool {
return currentImageURL == key || key == ShadowColorCache.noImageKey
}

}
Loading