From 79528d9639436d7d40fdd0161385ee84ba906187 Mon Sep 17 00:00:00 2001 From: hanjuheon Date: Tue, 17 Mar 2026 14:56:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?#=20=EB=A9=94=EC=9D=B8=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MVVM 구조 설계 - RxSwift 형식의 input, output 설계 - cardCell, listCell 구현 - 메인화면 설계 및 구현 - API 서비스 함수 생성 - 음악 모델 설계 --- Challenge/Challenge.xcodeproj/project.pbxproj | 456 ++++++++++++++++++ Challenge/Challenge/AppDelegate.swift | 81 ++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Challenge/Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../Challenge.xcdatamodeld/.xccurrentversion | 8 + .../Challenge.xcdatamodel/contents | 4 + Challenge/Challenge/Info.plist | 23 + Challenge/Challenge/Model/MusicModel.swift | 112 +++++ Challenge/Challenge/SceneDelegate.swift | 60 +++ Challenge/Challenge/Service/APIService.swift | 43 ++ Challenge/Challenge/View/View/CardCell.swift | 115 +++++ Challenge/Challenge/View/View/ListCell.swift | 122 +++++ .../View/View/SectionHeaderView.swift | 52 ++ .../ViewController/MainViewController.swift | 242 ++++++++++ .../Challenge/ViewModel/MainViewModel.swift | 66 +++ 17 files changed, 1461 insertions(+) create mode 100644 Challenge/Challenge.xcodeproj/project.pbxproj create mode 100644 Challenge/Challenge/AppDelegate.swift create mode 100644 Challenge/Challenge/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Challenge/Challenge/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Challenge/Challenge/Assets.xcassets/Contents.json create mode 100644 Challenge/Challenge/Base.lproj/LaunchScreen.storyboard create mode 100644 Challenge/Challenge/Challenge.xcdatamodeld/.xccurrentversion create mode 100644 Challenge/Challenge/Challenge.xcdatamodeld/Challenge.xcdatamodel/contents create mode 100644 Challenge/Challenge/Info.plist create mode 100644 Challenge/Challenge/Model/MusicModel.swift create mode 100644 Challenge/Challenge/SceneDelegate.swift create mode 100644 Challenge/Challenge/Service/APIService.swift create mode 100644 Challenge/Challenge/View/View/CardCell.swift create mode 100644 Challenge/Challenge/View/View/ListCell.swift create mode 100644 Challenge/Challenge/View/View/SectionHeaderView.swift create mode 100644 Challenge/Challenge/View/ViewController/MainViewController.swift create mode 100644 Challenge/Challenge/ViewModel/MainViewModel.swift diff --git a/Challenge/Challenge.xcodeproj/project.pbxproj b/Challenge/Challenge.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c9df31c --- /dev/null +++ b/Challenge/Challenge.xcodeproj/project.pbxproj @@ -0,0 +1,456 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 849AD61E2F63DC33007C837A /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 849AD61D2F63DC33007C837A /* RxCocoa */; }; + 849AD6202F63DC33007C837A /* RxRelay in Frameworks */ = {isa = PBXBuildFile; productRef = 849AD61F2F63DC33007C837A /* RxRelay */; }; + 849AD6222F63DC33007C837A /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 849AD6212F63DC33007C837A /* RxSwift */; }; + 849AD6252F63DC45007C837A /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 849AD6242F63DC45007C837A /* SnapKit */; }; + 849AD6282F63DC52007C837A /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 849AD6272F63DC52007C837A /* Then */; }; + 849AD62B2F63DC64007C837A /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 849AD62A2F63DC64007C837A /* Alamofire */; }; + 849AD8DB2F68F2E8007C837A /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 849AD8DA2F68F2E8007C837A /* Kingfisher */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 849AD6012F63DA4F007C837A /* Challenge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Challenge.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 849AD6162F63DA50007C837A /* Exceptions for "Challenge" folder in "Challenge" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 849AD6002F63DA4F007C837A /* Challenge */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 849AD6032F63DA4F007C837A /* Challenge */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 849AD6162F63DA50007C837A /* Exceptions for "Challenge" folder in "Challenge" target */, + ); + path = Challenge; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 849AD5FE2F63DA4F007C837A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 849AD6282F63DC52007C837A /* Then in Frameworks */, + 849AD6222F63DC33007C837A /* RxSwift in Frameworks */, + 849AD8DB2F68F2E8007C837A /* Kingfisher in Frameworks */, + 849AD6252F63DC45007C837A /* SnapKit in Frameworks */, + 849AD62B2F63DC64007C837A /* Alamofire in Frameworks */, + 849AD6202F63DC33007C837A /* RxRelay in Frameworks */, + 849AD61E2F63DC33007C837A /* RxCocoa in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 849AD5F82F63DA4F007C837A = { + isa = PBXGroup; + children = ( + 849AD6032F63DA4F007C837A /* Challenge */, + 849AD6022F63DA4F007C837A /* Products */, + ); + sourceTree = ""; + }; + 849AD6022F63DA4F007C837A /* Products */ = { + isa = PBXGroup; + children = ( + 849AD6012F63DA4F007C837A /* Challenge.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 849AD6002F63DA4F007C837A /* Challenge */ = { + isa = PBXNativeTarget; + buildConfigurationList = 849AD6172F63DA50007C837A /* Build configuration list for PBXNativeTarget "Challenge" */; + buildPhases = ( + 849AD5FD2F63DA4F007C837A /* Sources */, + 849AD5FE2F63DA4F007C837A /* Frameworks */, + 849AD5FF2F63DA4F007C837A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 849AD6032F63DA4F007C837A /* Challenge */, + ); + name = Challenge; + packageProductDependencies = ( + 849AD61D2F63DC33007C837A /* RxCocoa */, + 849AD61F2F63DC33007C837A /* RxRelay */, + 849AD6212F63DC33007C837A /* RxSwift */, + 849AD6242F63DC45007C837A /* SnapKit */, + 849AD6272F63DC52007C837A /* Then */, + 849AD62A2F63DC64007C837A /* Alamofire */, + 849AD8DA2F68F2E8007C837A /* Kingfisher */, + ); + productName = Challenge; + productReference = 849AD6012F63DA4F007C837A /* Challenge.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 849AD5F92F63DA4F007C837A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 849AD6002F63DA4F007C837A = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = 849AD5FC2F63DA4F007C837A /* Build configuration list for PBXProject "Challenge" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 849AD5F82F63DA4F007C837A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 849AD61C2F63DC33007C837A /* XCRemoteSwiftPackageReference "RxSwift" */, + 849AD6232F63DC45007C837A /* XCRemoteSwiftPackageReference "SnapKit" */, + 849AD6262F63DC52007C837A /* XCRemoteSwiftPackageReference "Then" */, + 849AD6292F63DC64007C837A /* XCRemoteSwiftPackageReference "Alamofire" */, + 849AD8D92F68F2E8007C837A /* XCRemoteSwiftPackageReference "Kingfisher" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 849AD6022F63DA4F007C837A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 849AD6002F63DA4F007C837A /* Challenge */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 849AD5FF2F63DA4F007C837A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 849AD5FD2F63DA4F007C837A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 849AD6182F63DA50007C837A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Challenge/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.Han.Challenge; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 849AD6192F63DA50007C837A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Challenge/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = kr.Han.Challenge; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 849AD61A2F63DA50007C837A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 849AD61B2F63DA50007C837A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 849AD5FC2F63DA4F007C837A /* Build configuration list for PBXProject "Challenge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 849AD61A2F63DA50007C837A /* Debug */, + 849AD61B2F63DA50007C837A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 849AD6172F63DA50007C837A /* Build configuration list for PBXNativeTarget "Challenge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 849AD6182F63DA50007C837A /* Debug */, + 849AD6192F63DA50007C837A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 849AD61C2F63DC33007C837A /* XCRemoteSwiftPackageReference "RxSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ReactiveX/RxSwift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 6.10.2; + }; + }; + 849AD6232F63DC45007C837A /* XCRemoteSwiftPackageReference "SnapKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SnapKit/SnapKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.7.1; + }; + }; + 849AD6262F63DC52007C837A /* XCRemoteSwiftPackageReference "Then" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/devxoul/Then.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; + 849AD6292F63DC64007C837A /* XCRemoteSwiftPackageReference "Alamofire" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Alamofire/Alamofire.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.11.1; + }; + }; + 849AD8D92F68F2E8007C837A /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.8.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 849AD61D2F63DC33007C837A /* RxCocoa */ = { + isa = XCSwiftPackageProductDependency; + package = 849AD61C2F63DC33007C837A /* XCRemoteSwiftPackageReference "RxSwift" */; + productName = RxCocoa; + }; + 849AD61F2F63DC33007C837A /* RxRelay */ = { + isa = XCSwiftPackageProductDependency; + package = 849AD61C2F63DC33007C837A /* XCRemoteSwiftPackageReference "RxSwift" */; + productName = RxRelay; + }; + 849AD6212F63DC33007C837A /* RxSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 849AD61C2F63DC33007C837A /* XCRemoteSwiftPackageReference "RxSwift" */; + productName = RxSwift; + }; + 849AD6242F63DC45007C837A /* SnapKit */ = { + isa = XCSwiftPackageProductDependency; + package = 849AD6232F63DC45007C837A /* XCRemoteSwiftPackageReference "SnapKit" */; + productName = SnapKit; + }; + 849AD6272F63DC52007C837A /* Then */ = { + isa = XCSwiftPackageProductDependency; + package = 849AD6262F63DC52007C837A /* XCRemoteSwiftPackageReference "Then" */; + productName = Then; + }; + 849AD62A2F63DC64007C837A /* Alamofire */ = { + isa = XCSwiftPackageProductDependency; + package = 849AD6292F63DC64007C837A /* XCRemoteSwiftPackageReference "Alamofire" */; + productName = Alamofire; + }; + 849AD8DA2F68F2E8007C837A /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 849AD8D92F68F2E8007C837A /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 849AD5F92F63DA4F007C837A /* Project object */; +} diff --git a/Challenge/Challenge/AppDelegate.swift b/Challenge/Challenge/AppDelegate.swift new file mode 100644 index 0000000..5a8e077 --- /dev/null +++ b/Challenge/Challenge/AppDelegate.swift @@ -0,0 +1,81 @@ +// +// AppDelegate.swift +// Challenge +// +// Created by Hanjuheon on 3/13/26. +// + +import UIKit +import CoreData + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + // MARK: - Core Data stack + + lazy var persistentContainer: NSPersistentContainer = { + /* + The persistent container for the application. This implementation + creates and returns a container, having loaded the store for the + application to it. This property is optional since there are legitimate + error conditions that could cause the creation of the store to fail. + */ + let container = NSPersistentContainer(name: "Challenge") + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + + /* + Typical reasons for an error here include: + * The parent directory does not exist, cannot be created, or disallows writing. + * The persistent store is not accessible, due to permissions or data protection when the device is locked. + * The device is out of space. + * The store could not be migrated to the current model version. + Check the error message to determine what the actual problem was. + */ + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + return container + }() + + // MARK: - Core Data Saving support + + func saveContext () { + let context = persistentContainer.viewContext + if context.hasChanges { + do { + try context.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + } + +} + diff --git a/Challenge/Challenge/Assets.xcassets/AccentColor.colorset/Contents.json b/Challenge/Challenge/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Challenge/Challenge/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Challenge/Challenge/Assets.xcassets/AppIcon.appiconset/Contents.json b/Challenge/Challenge/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Challenge/Challenge/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Challenge/Challenge/Assets.xcassets/Contents.json b/Challenge/Challenge/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Challenge/Challenge/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Challenge/Challenge/Base.lproj/LaunchScreen.storyboard b/Challenge/Challenge/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Challenge/Challenge/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Challenge/Challenge/Challenge.xcdatamodeld/.xccurrentversion b/Challenge/Challenge/Challenge.xcdatamodeld/.xccurrentversion new file mode 100644 index 0000000..c93e0b4 --- /dev/null +++ b/Challenge/Challenge/Challenge.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Challenge.xcdatamodel + + diff --git a/Challenge/Challenge/Challenge.xcdatamodeld/Challenge.xcdatamodel/contents b/Challenge/Challenge/Challenge.xcdatamodeld/Challenge.xcdatamodel/contents new file mode 100644 index 0000000..50d2514 --- /dev/null +++ b/Challenge/Challenge/Challenge.xcdatamodeld/Challenge.xcdatamodel/contents @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Challenge/Challenge/Info.plist b/Challenge/Challenge/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/Challenge/Challenge/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Challenge/Challenge/Model/MusicModel.swift b/Challenge/Challenge/Model/MusicModel.swift new file mode 100644 index 0000000..4f904f9 --- /dev/null +++ b/Challenge/Challenge/Model/MusicModel.swift @@ -0,0 +1,112 @@ +// +// MusicModel.swift +// Challenge +// +// Created by Hanjuheon on 3/16/26. +// + +struct MusicResponse: Decodable { + let resultCount: Int + let results: [Music] +} + +struct Music: Decodable, Hashable, Sendable { + /// 응답 데이터의 최상위 타입 + /// 예: "track" + let wrapperType: String? + + /// 결과 데이터의 종류 + /// 예: "song" + let kind: String? + + /// 아티스트 고유 ID + let artistId: Int? + + /// 앨범(컬렉션) 고유 ID + let collectionId: Int? + + /// 트랙(곡) 고유 ID + let trackId: Int + + /// 아티스트 이름 + let artistName: String? + + /// 앨범명 + let collectionName: String? + + /// 곡 제목 + let trackName: String? + + /// 아티스트 페이지 URL + let artistViewUrl: String? + + /// 앨범 페이지 URL + let collectionViewUrl: String? + + /// 곡 페이지 URL + let trackViewUrl: String? + + /// 곡 미리듣기 URL + let previewUrl: String? + + /// 60x60 사이즈 앨범 이미지 URL + let artworkUrl60: String? + + /// 100x100 사이즈 앨범 이미지 URL + let artworkUrl100: String? + + /// 앨범 가격 + let collectionPrice: Double? + + /// 곡 가격 + let trackPrice: Double? + + /// 발매일 + let releaseDate: String? + + /// 총 디스크 수 + let discCount: Int? + + /// 현재 곡이 속한 디스크 번호 + let discNumber: Int? + + /// 앨범 내 총 트랙 수 + let trackCount: Int? + + /// 앨범 내 현재 트랙 번호 + let trackNumber: Int? + + /// 곡 길이 (밀리초) + let trackTimeMillis: Int? + + /// 국가 코드 + let country: String? + + /// 대표 장르명 + let primaryGenreName: String? + + // Hashable 식별을 위해 가급적 유일한 값인 trackId를 기준으로 고정하는 것이 좋습니다. + func hash(into hasher: inout Hasher) { + hasher.combine(trackId) + } + + static func == (lhs: Music, rhs: Music) -> Bool { + return lhs.trackId == rhs.trackId + } +} + + +let mockMusics: [Music] = [ + Music(wrapperType: nil, kind: nil, artistId: 1, collectionId: 1, trackId: 1, artistName: "아이유", collectionName: "앨범1", trackName: "밤편지", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 2, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 3, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 4, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 5, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 6, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 7, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 8, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 9, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 10, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 11, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil), + Music(wrapperType: nil, kind: nil, artistId: 2, collectionId: 2, trackId: 12, artistName: "NewJeans", collectionName: "앨범2", trackName: "Ditto", artistViewUrl: nil, collectionViewUrl: nil, trackViewUrl: nil, previewUrl: nil, artworkUrl60: nil, artworkUrl100: nil, collectionPrice: nil, trackPrice: nil, releaseDate: nil, discCount: nil, discNumber: nil, trackCount: nil, trackNumber: nil, trackTimeMillis: nil, country: nil, primaryGenreName: nil) +] diff --git a/Challenge/Challenge/SceneDelegate.swift b/Challenge/Challenge/SceneDelegate.swift new file mode 100644 index 0000000..0003b20 --- /dev/null +++ b/Challenge/Challenge/SceneDelegate.swift @@ -0,0 +1,60 @@ +// +// SceneDelegate.swift +// Challenge +// +// Created by Hanjuheon on 3/13/26. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let windowSecne = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowSecne) + window.rootViewController = MainViewController() + window.makeKeyAndVisible() + self.window = window + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + + // Save changes in the application's managed object context when the application transitions to the background. + (UIApplication.shared.delegate as? AppDelegate)?.saveContext() + } + + +} + diff --git a/Challenge/Challenge/Service/APIService.swift b/Challenge/Challenge/Service/APIService.swift new file mode 100644 index 0000000..945f8e5 --- /dev/null +++ b/Challenge/Challenge/Service/APIService.swift @@ -0,0 +1,43 @@ +// +// APIService.swift +// Challenge +// +// Created by Hanjuheon on 3/16/26. +// + +import Foundation +import RxSwift + +enum NetworkError : Error { + case invalidURL + case dataFetchFail + case decodingFail +} + +class APIService { + static let share = APIService() + private init() {} + + func fetch(url: URL) -> Single { + return Single.create { observer in + let session = URLSession(configuration: .default) + session.dataTask(with:URLRequest(url: url)) { data, response, error in + if let error = error { + observer(.failure(error)) + } + guard let data = data, + let response = response as? HTTPURLResponse else { + observer(.failure(NetworkError.dataFetchFail)) + return + } + do { + let decodeData = try JSONDecoder().decode(T.self, from: data) + observer(.success(decodeData)) + } catch { + observer(.failure(NetworkError.decodingFail)) + } + }.resume() + return Disposables.create() + } + } +} diff --git a/Challenge/Challenge/View/View/CardCell.swift b/Challenge/Challenge/View/View/CardCell.swift new file mode 100644 index 0000000..8d8b521 --- /dev/null +++ b/Challenge/Challenge/View/View/CardCell.swift @@ -0,0 +1,115 @@ +// +// CardCell.swift +// Challenge +// +// Created by Hanjuheon on 3/13/26. +// + +import UIKit +import Then +import SnapKit +import Kingfisher + +/// 카드형식 셀 +class CardCell: UICollectionViewCell { + + //MARK: - Properties + static let cardCellIdentifier = "CardCell" + + //MARK: - Components + private let imageView = UIImageView().then { + $0.contentMode = .scaleToFill + $0.clipsToBounds = true + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.systemGray6.cgColor + } + + private let titleLabel = UILabel().then { + $0.font = .boldSystemFont(ofSize: 16) + $0.textColor = .black + $0.numberOfLines = 0 + $0.textAlignment = .natural + $0.lineBreakMode = .byTruncatingTail + $0.text = "곡명" + } + + private let subTitleLabel = UILabel().then { + $0.font = .systemFont(ofSize: 14) + $0.textColor = .systemGray2 + $0.numberOfLines = 0 + $0.textAlignment = .natural + $0.lineBreakMode = .byTruncatingTail + $0.text = "엘범 ・ 가수" + } + + //MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + titleLabel.text = "" + subTitleLabel.text = "" + } +} + +//MARK: - Update UI +extension CardCell { + func updateUI(music: Music){ + titleLabel.text = music.trackName ?? "제목 없음" + subTitleLabel.text = music.artistName ?? "아티스트 정보 없음" + + guard let urlString = music.artworkUrl100 else { + return + } + + imageView.kf.setImage(with: URL(string: urlString)) + } +} +//MARK: - Configure UI +extension CardCell { + func configureUI() { + let stackView = UIStackView().then{ + $0.axis = .vertical + $0.distribution = .fillProportionally + $0.spacing = 5 + } + + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(subTitleLabel) + + contentView.addSubview(stackView) + + stackView.snp.makeConstraints { + $0.center.equalToSuperview().inset(10) + } + + imageView.snp.makeConstraints { + $0.height.width.equalTo(150) + } + + titleLabel.snp.makeConstraints { + $0.height.equalTo(18) + } + + subTitleLabel.snp.makeConstraints { + $0.height.equalTo(16) + } + } +} + + +@available(iOS 17.0, *) +#Preview(traits: .fixedLayout(width: 250, height: 350)){ + CardCell() +} diff --git a/Challenge/Challenge/View/View/ListCell.swift b/Challenge/Challenge/View/View/ListCell.swift new file mode 100644 index 0000000..cd9c9c9 --- /dev/null +++ b/Challenge/Challenge/View/View/ListCell.swift @@ -0,0 +1,122 @@ +// +// ListCell.swift +// Challenge +// +// Created by Hanjuheon on 3/13/26. +// + +import UIKit +import Then +import SnapKit +import Kingfisher + +/// 리스트형식 셀 +class ListCell: UICollectionViewCell { + + //MARK: - Properties + static let listCellIdentifier = "ListCell" + + //MARK: - Components + private let imageView = UIImageView().then { + $0.contentMode = .scaleToFill + $0.clipsToBounds = true + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.systemGray6.cgColor + } + + private let titleLabel = UILabel().then { + $0.font = .boldSystemFont(ofSize: 14) + $0.textColor = .black + $0.numberOfLines = 0 + $0.textAlignment = .natural + $0.lineBreakMode = .byTruncatingTail + $0.text = "곡명" + } + + private let subTitleLabel = UILabel().then { + $0.font = .systemFont(ofSize: 12) + $0.textColor = .systemGray2 + $0.numberOfLines = 0 + $0.textAlignment = .natural + $0.lineBreakMode = .byTruncatingTail + $0.text = "엘범 ・ 가수" + } + + //MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("ListCell FatalError") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + titleLabel.text = "" + subTitleLabel.text = "" + } +} + +//MARK: - Update UI +extension ListCell { + func updateUI(music: Music){ + titleLabel.text = music.trackName ?? "제목 없음" + subTitleLabel.text = music.artistName ?? "아티스트 정보 없음" + + guard let urlString = music.artworkUrl60 else { + return + } + + imageView.kf.setImage(with: URL(string: urlString)) + } +} + + +//MARK: - configure UI + +extension ListCell { + func configureUI() { + let stackView = UIStackView().then { + $0.axis = .horizontal + $0.distribution = .fill + $0.spacing = 5 + } + + let titleStackView = UIStackView().then { + $0.axis = .vertical + $0.distribution = .fill + $0.spacing = 0 + } + + titleStackView.addArrangedSubview(titleLabel) + titleStackView.addArrangedSubview(subTitleLabel) + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleStackView) + + contentView.addSubview(stackView) + + + stackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(10) + } + + titleStackView.snp.makeConstraints { + $0.trailing.equalToSuperview() + } + + imageView.snp.makeConstraints { + $0.size.equalTo(60) + } + } +} + + +@available(iOS 17.0, *) +#Preview{ + ListCell() +} diff --git a/Challenge/Challenge/View/View/SectionHeaderView.swift b/Challenge/Challenge/View/View/SectionHeaderView.swift new file mode 100644 index 0000000..ccef31a --- /dev/null +++ b/Challenge/Challenge/View/View/SectionHeaderView.swift @@ -0,0 +1,52 @@ +// +// SectionHeaderView.swift +// Challenge +// +// Created by Hanjuheon on 3/16/26. +// + +import UIKit +import Then +import SnapKit + +// 섹션 헤더 +class SectionHeaderView: UICollectionReusableView { + + //MARK: - Properties + static let id = "SectionHeader" + + //MARK: - Components + let titleLabel = UILabel().then { + $0.font = .boldSystemFont(ofSize: 24) + $0.textColor = .black + $0.textAlignment = .natural + $0.text = "타이틀" + } + + //MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + } + + required init?(coder: NSCoder) { + fatalError() + } + + //MARK: - Update UI + func setTtitle(title: String) { + titleLabel.text = title + } + + //MARK: - Configure UI + private func configureUI() { + addSubview(titleLabel) + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview().offset(10) + $0.trailing.equalToSuperview().inset(10) + $0.top.equalToSuperview().offset(5) + $0.bottom.equalToSuperview().inset(5) + } + } +} + diff --git a/Challenge/Challenge/View/ViewController/MainViewController.swift b/Challenge/Challenge/View/ViewController/MainViewController.swift new file mode 100644 index 0000000..9f45e75 --- /dev/null +++ b/Challenge/Challenge/View/ViewController/MainViewController.swift @@ -0,0 +1,242 @@ +// +// ViewController.swift +// Challenge +// +// Created by Hanjuheon on 3/13/26. +// + +import UIKit +import RxCocoa +import RxSwift +import Then +import SnapKit + +// 메인화면 컨트롤 +class MainViewController: UIViewController { + + //MARK: - ViewModel + var vm: MainViewModel + + private var disposeBag = DisposeBag() + + //MARK: - Components + lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + + let searchController = UISearchController(searchResultsController: nil) + + private var dataSource: UICollectionViewDiffableDataSource! + + //MARK: - Init + override func viewDidLoad() { + super.viewDidLoad() + title = "Apple Music" + // Do any additional setup after loading the view. + configureUI() + configureDataSource() + bind() + } + + init() { + self.vm = MainViewModel() + super.init(nibName: nil, bundle: nil) + } + + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +//MARK: - Bind +extension MainViewController { + func bind() { + let input = MainViewModel.Input( + fetch: .just(()), + ) + + let output = vm.transform(input: input) + + output.update.subscribe( + onNext: { [weak self] dic in + guard let self else { return } + self.applySnapshot(with: dic) + } + ).disposed(by: disposeBag) + } + +} + +//MARK: - Search Controller +extension MainViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + + } +} + +//MARK: - CollectionView +extension MainViewController { + private func applySnapshot(with dic: [SeasonKeyword: [Music]]) { + var snapShot = NSDiffableDataSourceSnapshot() + snapShot.appendSections(SeasonKeyword.allCases) + + snapShot.appendItems(dic[.spring] ?? [Music](), toSection: .spring) + snapShot.appendItems(dic[.summer] ?? [Music](), toSection: .summer) + snapShot.appendItems(dic[.autumn] ?? [Music](), toSection: .autumn) + snapShot.appendItems(dic[.winter] ?? [Music](), toSection: .winter) + + dataSource.apply(snapShot, animatingDifferences: true) + } + + + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView) { [weak self] + collectionView, indexPath, item in + + guard let self else { return UICollectionViewCell() } + + let section = self.dataSource.snapshot().sectionIdentifiers + let sectionType = section[indexPath.section] + + switch sectionType { + case .spring, .autumn: + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CardCell.cardCellIdentifier, for: indexPath) as? CardCell else { + return UICollectionViewCell() } + cell.updateUI(music: item) + + return cell + case .summer, .winter: + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ListCell.listCellIdentifier, for: indexPath) as? ListCell else { + return UICollectionViewCell() } + cell.updateUI(music: item) + + return cell + } + } + dataSource.supplementaryViewProvider = {[weak self] collectionView, kind, indexPath in + guard let self else { return nil } + + guard kind == UICollectionView.elementKindSectionHeader else { + return nil + } + + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: SectionHeaderView.id, + for: indexPath) as? SectionHeaderView else { + return nil + } + + let sections = self.dataSource.snapshot().sectionIdentifiers + let sectionType = sections[indexPath.section] + + header.setTtitle(title: sectionType.title) + return header + } + } + + private func createLayout() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { [weak self] (sectionIndex, _) -> + NSCollectionLayoutSection? in + guard let self else { return nil } + + let section = self.dataSource.snapshot().sectionIdentifiers + let sectionType = section[sectionIndex] + + switch sectionType { + case .spring, .autumn: + return self.cardSection() + case .summer, .winter: + return self.listSection() + } + } + } + + private func sectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem { + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: .init( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(44) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + } + + private func cardSection()-> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem( + layoutSize: .init( + widthDimension: .absolute(160), + heightDimension: .absolute(190) + ) + ) + + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: .init( + widthDimension: .absolute(160), + heightDimension: .absolute(190) + ), + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 8 + section.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 10, bottom: 40, trailing: 10) + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [sectionHeader()] + + return section + } + + private func listSection() -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem(layoutSize: + .init(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0 / 3.0))) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: + .init(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(240)), + repeatingSubitem: item, + count: 3) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .groupPaging + section.interGroupSpacing = 5 + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 10, trailing: 40) + section.boundarySupplementaryItems = [sectionHeader()] + + return section + } +} + +//MARK: - ConfigureUI +extension MainViewController { + private func configureUI() { + navigationItem.searchController = searchController + navigationItem.preferredSearchBarPlacement = .stacked + searchController.searchResultsUpdater = self + + collectionView.register(CardCell.self, forCellWithReuseIdentifier: CardCell.cardCellIdentifier) + collectionView.register(ListCell.self, forCellWithReuseIdentifier: ListCell.listCellIdentifier) + collectionView.register(SectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: SectionHeaderView.id) + + + view.addSubview(collectionView) + + collectionView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide).offset(5) + $0.bottom.equalToSuperview() + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview() + } + } +} + + + +@available(iOS 17.0, *) +#Preview { + UINavigationController(rootViewController: MainViewController()) +} diff --git a/Challenge/Challenge/ViewModel/MainViewModel.swift b/Challenge/Challenge/ViewModel/MainViewModel.swift new file mode 100644 index 0000000..acf0b6b --- /dev/null +++ b/Challenge/Challenge/ViewModel/MainViewModel.swift @@ -0,0 +1,66 @@ +// +// MainViewModel.swift +// Challenge +// +// Created by Hanjuheon on 3/16/26. +// + +import Foundation +import RxSwift + +enum SeasonKeyword: String, CaseIterable, Hashable { + case spring = "봄" + case summer = "여름" + case autumn = "가을" + case winter = "겨울" + + var title: String { + rawValue + } +} + +/// MainView Model +class MainViewModel { + struct Input { + let fetch: Observable + } + + struct Output { + let update: Observable<[SeasonKeyword : [Music]]> + } + + func transform(input: Input) -> Output { + let update = input.fetch + // input.fetch에서 이벤트가 발생하면 Observable<[SeasonKeyword: [Music]]> 스트림을 생성 + // 새로운 fetch 이벤트가 오면 이전 fetch로 만든 스트림은 더 이상 보지 않고 가장 최근 fetch로 만든 스트림만 구독 (flatMapLatest) + .flatMapLatest { _ -> Observable<[SeasonKeyword : [Music]]> in + // SeasonKeyword 전체 케이스를 배열로 담고 map 실행 + // 결과값:(Observable) 튜플 형태의 배열 제작 + let results = SeasonKeyword.allCases.map { keyword in + // url 체크 + guard let url = URL(string: "https://itunes.apple.com/search?media=music&country=KR&term=\(keyword.title)") else { + return Observable.just((keyword, [Music]())) + } + + // API 호출 + return APIService.share.fetch(url: url) + // 호출 결과 값을 튜플로 묶음 + .map { (musicResponse: MusicResponse) in + (keyword, musicResponse.results) + } + // Observable로 생성 + .asObservable() + } + + //merge를 통해 옵져버 배열을 하나의 스트림으로 통합 + return Observable.merge(results) + //scan 을 이용하여 스트림의 결과값들을 딕셔너리 형태로 누적 + .scan(into: [SeasonKeyword : [Music]]()) { partial, element in + let (keyword, musics) = element + partial[keyword] = musics + } + } + + return Output(update: update) + } +} From 2cee8dc388f87ecfec10aa74573c66470961b521 Mon Sep 17 00:00:00 2001 From: hanjuheon Date: Thu, 19 Mar 2026 09:26:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상단 검색 바 기능 구현 - 검색 결과 화면 설계 및 구현 --- Challenge/Challenge/Model/PodcastModel.swift | 44 ++++ Challenge/Challenge/View/View/CardCell.swift | 2 +- Challenge/Challenge/View/View/ListCell.swift | 2 +- .../Challenge/View/View/SearchCardCell.swift | 174 ++++++++++++++ .../ViewController/MainViewController.swift | 33 ++- .../SearchResultViewController.swift | 226 ++++++++++++++++++ .../Challenge/ViewModel/MainViewModel.swift | 1 + .../ViewModel/SearchResultViewModel.swift | 58 +++++ 8 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 Challenge/Challenge/Model/PodcastModel.swift create mode 100644 Challenge/Challenge/View/View/SearchCardCell.swift create mode 100644 Challenge/Challenge/View/ViewController/SearchResultViewController.swift create mode 100644 Challenge/Challenge/ViewModel/SearchResultViewModel.swift diff --git a/Challenge/Challenge/Model/PodcastModel.swift b/Challenge/Challenge/Model/PodcastModel.swift new file mode 100644 index 0000000..b33f9b5 --- /dev/null +++ b/Challenge/Challenge/Model/PodcastModel.swift @@ -0,0 +1,44 @@ +// +// PodcastModel.swift +// Challenge +// +// Created by Hanjuheon on 3/17/26. +// + +import Foundation + +struct PodcastResponse: Decodable { + let resultCount: Int + let results: [Podcast] +} + +struct Podcast: Decodable, Hashable { + let wrapperType: String? + let kind: String? + let trackId: Int? + let artistName: String? + let collectionName: String? + let trackName: String? + + let artworkUrl60: String? + let artworkUrl600: String? + + let collectionPrice: Double? + let trackPrice: Double? + let collectionHdPrice: Double? + let releaseDate: String? + + let collectionExplicitness: String? + let trackExplicitness: String? + + let trackCount: Int? + let trackTimeMillis: Int? + + let country: String? + let currency: String? + let primaryGenreName: String? + let contentAdvisoryRating: String? + + let genreIds: [String]? + let genres: [String]? +} diff --git a/Challenge/Challenge/View/View/CardCell.swift b/Challenge/Challenge/View/View/CardCell.swift index 8d8b521..564a20a 100644 --- a/Challenge/Challenge/View/View/CardCell.swift +++ b/Challenge/Challenge/View/View/CardCell.swift @@ -14,7 +14,7 @@ import Kingfisher class CardCell: UICollectionViewCell { //MARK: - Properties - static let cardCellIdentifier = "CardCell" + static let id = "CardCell" //MARK: - Components private let imageView = UIImageView().then { diff --git a/Challenge/Challenge/View/View/ListCell.swift b/Challenge/Challenge/View/View/ListCell.swift index cd9c9c9..5c6c555 100644 --- a/Challenge/Challenge/View/View/ListCell.swift +++ b/Challenge/Challenge/View/View/ListCell.swift @@ -14,7 +14,7 @@ import Kingfisher class ListCell: UICollectionViewCell { //MARK: - Properties - static let listCellIdentifier = "ListCell" + static let id = "ListCell" //MARK: - Components private let imageView = UIImageView().then { diff --git a/Challenge/Challenge/View/View/SearchCardCell.swift b/Challenge/Challenge/View/View/SearchCardCell.swift new file mode 100644 index 0000000..8944355 --- /dev/null +++ b/Challenge/Challenge/View/View/SearchCardCell.swift @@ -0,0 +1,174 @@ +// +// SearchResultCell.swift +// Challenge +// +// Created by Hanjuheon on 3/17/26. +// + +import UIKit +import Then +import SnapKit +import Kingfisher + +class SearchCardCell: UICollectionViewCell { + + //MARK: - Properties + static let id = "SearchCardCell" + + //MARK: - Components + private let headerImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + } + + private let subImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .black + } + + private let titleLabel = UILabel().then { + $0.textColor = .white + $0.numberOfLines = 0 + $0.lineBreakMode = .byTruncatingTail + $0.textAlignment = .natural + $0.font = .boldSystemFont(ofSize: 24) + $0.text = "타이틀 명" + } + + private let genresLabel = UILabel().then { + $0.textColor = .white + $0.numberOfLines = 0 + $0.lineBreakMode = .byTruncatingTail + $0.textAlignment = .natural + $0.font = .systemFont(ofSize: 16) + $0.text = "장르 종류" + } + + private let artistLabel = UILabel().then { + $0.textColor = .black + $0.numberOfLines = 0 + $0.lineBreakMode = .byTruncatingTail + $0.textAlignment = .natural + $0.font = .boldSystemFont(ofSize: 18) + $0.text = "아티스트" + } + + private let trackLabel = UILabel().then { + $0.textColor = .systemGray2 + $0.numberOfLines = 0 + $0.lineBreakMode = .byTruncatingTail + $0.textAlignment = .natural + $0.font = .systemFont(ofSize: 16) + $0.text = "에피소드 갯수 ⦁ 총 상영시간" + } + + //MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + } + + required init?(coder: NSCoder) { + fatalError() + } +} + +//MARK: - Update UI +extension SearchCardCell { + func updateUI(podcast: Podcast) { + titleLabel.text = podcast.collectionName + genresLabel.text = podcast.genres?.joined(separator: " · ") ?? "" + artistLabel.text = podcast.artistName + trackLabel.text = " 총 \(podcast.trackCount ?? 0) 에피소드" + + guard let mainImageUrl = podcast.artworkUrl600, + let subImageUrl = podcast.artworkUrl60 else { return + } + + headerImageView.kf.setImage(with: URL(string: mainImageUrl)) + subImageView.kf.setImage(with: URL(string: subImageUrl)) + } +} + +//MARK: - Configure UI +extension SearchCardCell { + func configureUI() { + let view = UIView().then { + $0.backgroundColor = .clear + $0.clipsToBounds = true + $0.layer.cornerRadius = 20 + $0.layer.borderColor = UIColor.systemGray6.cgColor + $0.layer.borderWidth = 0.5 + } + + let subView = UIView().then { + $0.backgroundColor = UIColor(white: 0.9, alpha: 0.5) + + } + + let titleStackView = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + } + + let subStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 20 + $0.alignment = .fill + } + + let infoStackView = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + } + + infoStackView.addArrangedSubview(artistLabel) + infoStackView.addArrangedSubview(trackLabel) + subStackView.addArrangedSubview(subImageView) + subStackView.addArrangedSubview(infoStackView) + titleStackView.addArrangedSubview(titleLabel) + titleStackView.addArrangedSubview(genresLabel) + + subView.addSubview(subStackView) + view.addSubview(headerImageView) + view.addSubview(titleStackView) + view.addSubview(subView) + + contentView.addSubview(view) + + view.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + headerImageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + subView.snp.makeConstraints { + $0.bottom.equalTo(view.snp.bottom) + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview() + $0.height.equalTo(80) + } + + titleStackView.snp.makeConstraints { + $0.bottom.equalTo(subView.snp.top).offset(-10) + $0.leading.equalToSuperview().offset(10) + $0.trailing.equalToSuperview() + $0.height.equalTo(80) + } + + subStackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(10) + } + + subImageView.snp.makeConstraints { + $0.size.equalTo(60) + } + } +} + +@available(iOS 17.0, *) +#Preview { + SearchCardCell() +} diff --git a/Challenge/Challenge/View/ViewController/MainViewController.swift b/Challenge/Challenge/View/ViewController/MainViewController.swift index 9f45e75..bdec6ff 100644 --- a/Challenge/Challenge/View/ViewController/MainViewController.swift +++ b/Challenge/Challenge/View/ViewController/MainViewController.swift @@ -21,8 +21,8 @@ class MainViewController: UIViewController { //MARK: - Components lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) - - let searchController = UISearchController(searchResultsController: nil) + + lazy var searchController = UISearchController(searchResultsController: SearchResultViewController(vm: SearchResultViewModel())) private var dataSource: UICollectionViewDiffableDataSource! @@ -34,6 +34,7 @@ class MainViewController: UIViewController { configureUI() configureDataSource() bind() + bindToSearch() } init() { @@ -65,15 +66,17 @@ extension MainViewController { ).disposed(by: disposeBag) } -} - -//MARK: - Search Controller -extension MainViewController: UISearchResultsUpdating { - func updateSearchResults(for searchController: UISearchController) { + func bindToSearch() { + guard let searchVC = searchController.searchResultsController as? SearchResultViewController else { return } + searchController.searchBar.rx.text.orEmpty + .distinctUntilChanged() + .bind(to: searchVC.searchRelay) + .disposed(by: disposeBag) } } + //MARK: - CollectionView extension MainViewController { private func applySnapshot(with dic: [SeasonKeyword: [Music]]) { @@ -101,13 +104,13 @@ extension MainViewController { switch sectionType { case .spring, .autumn: - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CardCell.cardCellIdentifier, for: indexPath) as? CardCell else { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CardCell.id, for: indexPath) as? CardCell else { return UICollectionViewCell() } cell.updateUI(music: item) return cell case .summer, .winter: - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ListCell.listCellIdentifier, for: indexPath) as? ListCell else { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ListCell.id, for: indexPath) as? ListCell else { return UICollectionViewCell() } cell.updateUI(music: item) @@ -216,10 +219,16 @@ extension MainViewController { private func configureUI() { navigationItem.searchController = searchController navigationItem.preferredSearchBarPlacement = .stacked - searchController.searchResultsUpdater = self - collectionView.register(CardCell.self, forCellWithReuseIdentifier: CardCell.cardCellIdentifier) - collectionView.register(ListCell.self, forCellWithReuseIdentifier: ListCell.listCellIdentifier) + // 검색 활성화 시 뒤에 있던 기존 화면을 어둡게 가릴지 여부 + searchController.obscuresBackgroundDuringPresentation = true + // 검색창 안에 회색 안내 문구 표시 + searchController.searchBar.placeholder = "검색" + // SearchController의 표시 범위와 화면 전환을 관리하도록 설정 + definesPresentationContext = true + + collectionView.register(CardCell.self, forCellWithReuseIdentifier: CardCell.id) + collectionView.register(ListCell.self, forCellWithReuseIdentifier: ListCell.id) collectionView.register(SectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: SectionHeaderView.id) diff --git a/Challenge/Challenge/View/ViewController/SearchResultViewController.swift b/Challenge/Challenge/View/ViewController/SearchResultViewController.swift new file mode 100644 index 0000000..e7934c9 --- /dev/null +++ b/Challenge/Challenge/View/ViewController/SearchResultViewController.swift @@ -0,0 +1,226 @@ +// +// SearchResultViewController.swift +// Challenge +// +// Created by Hanjuheon on 3/17/26. +// + +import UIKit +import Then +import SnapKit +import RxSwift +import RxCocoa + + +class SearchResultViewController: UIViewController { + + //MARK: - ViewModel + var vm: SearchResultViewModel + + //MARK: - Properties + var searchRelay = PublishRelay() + + let disposeBag = DisposeBag() + + //MARK: - Components + lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + + private var dataSource: UICollectionViewDiffableDataSource! + + //MARK: - Init + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + configureUI() + configureDataSource() + bind() + } + + init(vm: SearchResultViewModel) { + self.vm = vm + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +//MARK: - Binding +extension SearchResultViewController { + func bind() { + let input = SearchResultViewModel.Input( + fatch: searchRelay.asObservable() + ) + + let output = vm.transForm(input: input) + + output.fatchItem + .subscribe( + onNext: { [weak self] item in + guard let self else { return } + self.applySnapShot(with: item.music, with: item.podcast) + } + ).disposed(by: disposeBag) + } +} + +//MARK: - CollectionView Setting +extension SearchResultViewController { + + func applySnapShot(with music: [Music], with podcast: [Podcast]) { + var snapshot = NSDiffableDataSourceSnapshot() + + if !music.isEmpty { + snapshot.appendSections([.music]) + let musicData = music.map { SearchItem.music($0) } + snapshot.appendItems(musicData, toSection: .music) + } + + if !podcast.isEmpty { + snapshot.appendSections([.podcast]) + let podcastData = podcast.map { SearchItem.podcast($0) } + snapshot.appendItems(podcastData, toSection: .podcast) + } + + dataSource.apply(snapshot, animatingDifferences: true) + } + + + func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource (collectionView: collectionView) { [weak self] + collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + + switch item { + case .podcast(let podcast): + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: SearchCardCell.id, + for: indexPath + ) as? SearchCardCell else { + return UICollectionViewCell() + } + cell.updateUI(podcast: podcast) + return cell + + case .music(let music): + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ListCell.id, + for: indexPath + ) as? ListCell else { + return UICollectionViewCell() + } + cell.updateUI(music: music) + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + guard let self else { return nil } + guard kind == UICollectionView.elementKindSectionHeader else { + return nil + } + + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: SectionHeaderView.id, + for: indexPath) as? SectionHeaderView else { return nil } + + let sections = self.dataSource.snapshot().sectionIdentifiers + let sectionType = sections[indexPath.section] + + header.setTtitle(title: sectionType.title) + return header + } + } + + + func createLayout() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { [weak self] + (sectionIndex, _) -> NSCollectionLayoutSection? in + guard let self else { return nil } + + let sections = self.dataSource.snapshot().sectionIdentifiers + let sectionType = sections[sectionIndex] + + switch sectionType { + case .podcast: + return self.CardSection() + case .music: + return self.listSection() + } + } + } + + private func sectionHeader()-> NSCollectionLayoutBoundarySupplementaryItem { + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: .init( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(44)), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top) + } + + func CardSection() -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: .init(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalWidth(1.0)), + subitems: [item]) + group.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 40, leading: 0, bottom: 10, trailing: 40) + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [sectionHeader()] + + return section + } + + func listSection() -> NSCollectionLayoutSection { + let item = NSCollectionLayoutItem(layoutSize: + .init(widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0 / 5.0))) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: + .init(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(400)), + repeatingSubitem: item, + count: 5) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 10, trailing: 40) + section.orthogonalScrollingBehavior = .groupPaging + section.boundarySupplementaryItems = [sectionHeader()] + + return section + } +} + +//MARK: - Configure UI +extension SearchResultViewController { + func configureUI() { + collectionView.register(SearchCardCell.self, forCellWithReuseIdentifier: SearchCardCell.id) + collectionView.register(ListCell.self, forCellWithReuseIdentifier: ListCell.id) + collectionView.register(SectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: SectionHeaderView.id) + + view.addSubview(collectionView) + + collectionView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(10) + $0.trailing.equalToSuperview().inset(10) + } + } +} + + +@available(iOS 17.0, *) +#Preview { + SearchResultViewController(vm: SearchResultViewModel()) +} + diff --git a/Challenge/Challenge/ViewModel/MainViewModel.swift b/Challenge/Challenge/ViewModel/MainViewModel.swift index acf0b6b..24eced4 100644 --- a/Challenge/Challenge/ViewModel/MainViewModel.swift +++ b/Challenge/Challenge/ViewModel/MainViewModel.swift @@ -23,6 +23,7 @@ enum SeasonKeyword: String, CaseIterable, Hashable { class MainViewModel { struct Input { let fetch: Observable + } struct Output { diff --git a/Challenge/Challenge/ViewModel/SearchResultViewModel.swift b/Challenge/Challenge/ViewModel/SearchResultViewModel.swift new file mode 100644 index 0000000..469f84f --- /dev/null +++ b/Challenge/Challenge/ViewModel/SearchResultViewModel.swift @@ -0,0 +1,58 @@ +// +// SearchResultViewModel.swift +// Challenge +// +// Created by Hanjuheon on 3/18/26. +// + +import Foundation +import RxSwift + + +enum SearchItem: Hashable { + case podcast(Podcast) + case music(Music) +} + +enum MediaType: String, CaseIterable, Hashable { + case podcast = "podcast" + case music = "music" + + var title: String { + rawValue + } +} + +class SearchResultViewModel { + struct Input { + let fatch: Observable + } + + struct Output { + let fatchItem: Observable<(music: [Music], podcast: [Podcast])> + } + + func transForm(input: Input) -> Output { + let fatch = input.fatch + .distinctUntilChanged() + .flatMapLatest { query -> Observable<(music: [Music], podcast: [Podcast])> in + guard let musicUrl = URL(string: "https://itunes.apple.com/search?media=music&country=KR&term=\(query)"), + let podCastUrl = URL(string: "https://itunes.apple.com/search?media=podcast&country=KR&term=\(query)") + else { return .just((music: [], podcast: [])) } + + let musicFatch = APIService.share.fetch(url: musicUrl).map { (musicResponse: MusicResponse) in + musicResponse.results }.asObservable() + + let podcastFatch = APIService.share.fetch(url: podCastUrl).map{ (podcastResponse: PodcastResponse) in + podcastResponse.results }.asObservable() + + return Observable.combineLatest(musicFatch, podcastFatch) { musics, podcasts in + (music: musics, podcast: podcasts) + } + } + return Output( + fatchItem: fatch + ) + } + +} From 96a9077b6791b8ee287dafe102207834110b47f7 Mon Sep 17 00:00:00 2001 From: Han_Juheon Date: Thu, 19 Mar 2026 09:59:51 +0900 Subject: [PATCH 3/3] Enhance README with project details and features Added detailed project description, goals, and features for the iOS app inspired by Apple Music. --- README.md | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 87fc2f5..e7d2d2d 100644 --- a/README.md +++ b/README.md @@ -1 +1,167 @@ -# Challenge \ No newline at end of file +# Challenge + +Apple Music 스타일의 인터페이스를 참고하여 +음악과 팟캐스트를 탐색할 수 있도록 구현한 iOS 앱입니다. + +iTunes Search API를 활용해 데이터를 불러오고, +RxSwift 기반의 MVVM 구조로 메인 화면과 검색 결과 화면을 구성했습니다. + + +--- + +## 프로젝트 소개 + +이 프로젝트는 Apple Music 형태의 탐색 경험을 iOS 앱으로 구현하는 것을 목표로 했습니다. + +메인 화면에서는 계절 키워드(봄, 여름, 가을, 겨울)를 기준으로 음악 데이터를 구분해 보여주고, +검색 화면에서는 사용자의 입력값에 따라 음악과 팟캐스트 결과를 함께 제공합니다. + +단순히 데이터를 보여주는 것에 그치지 않고, +각 섹션에 맞는 레이아웃과 셀 타입을 분리하여 화면 구조를 설계했습니다. + +--- + +## 개발 목표 + +- UIKit 기반 화면 설계 연습 +- MVVM 패턴과 RxSwift Input / Output 구조 적용 +- CollectionView Compositional Layout 활용 +- DiffableDataSource를 이용한 동적 UI 업데이트 +- 외부 API 연동 및 비동기 데이터 처리 경험 + +--- + +## 사용 기술 + +- **MVVM** +- **UIKit** +- **RxSwift** +- **RxCocoa** +- **SnapKit** +- **Then** +- **Kingfisher** + +--- + +## 주요 기능 + +### 1. 메인 화면 구성 +- Apple Music 스타일의 메인 화면 구현 +- 봄 / 여름 / 가을 / 겨울 키워드 기준으로 음악 데이터를 분류 +- 섹션별 헤더 제공 +- 카드형 셀과 리스트형 셀을 혼합해 레이아웃 구성 + +### 2. 검색 기능 +- UISearchController 기반 검색 인터페이스 구현 +- 검색어 입력 시 검색 결과 화면으로 실시간 반영 +- 음악과 팟캐스트 데이터를 동시에 조회 +- 결과를 섹션별로 분리하여 표시 + +### 3. 반응형 데이터 바인딩 +- ViewController → ViewModel로 Input 전달 +- ViewModel → ViewController로 Output 방출 +- RxSwift를 활용한 비동기 데이터 흐름 처리 + +--- + +## 화면 구성 + +### 메인 화면 +- 계절 키워드별 음악 추천 섹션 +- CardCell / ListCell 혼합 구성 +- DiffableDataSource 기반 스냅샷 적용 + +### 검색 결과 화면 +- Music 섹션 +- Podcast 섹션 +- 검색 결과에 따라 동적으로 섹션 구성 + +--- + +## 내가 구현한 내용 + +- MVVM 구조 설계 +- RxSwift Input / Output 패턴 적용 +- 메인 화면 UI 설계 및 구현 +- CardCell / ListCell / SearchCardCell 구현 +- SectionHeaderView 구현 +- APIService 생성 +- Music / Podcast 모델 설계 +- 검색 결과 화면 설계 및 구현 +- 음악 / 팟캐스트 통합 검색 로직 구현 + +--- + +## 구현 포인트 + +### 1. 계절별 데이터를 하나의 화면에 구성 +메인 화면에서는 계절 키워드를 기준으로 각각 API를 호출한 뒤, +응답 결과를 하나의 딕셔너리 형태로 누적하여 섹션별 데이터로 관리했습니다. + +이를 통해 동일한 구조의 비동기 작업을 반복하면서도 +화면에서는 일관된 형태로 데이터를 렌더링할 수 있었습니다. + +### 2. DiffableDataSource를 활용한 UI 갱신 +CollectionView 데이터 갱신 시 reload 방식 대신 snapshot 기반으로 처리하여 +섹션 및 아이템 변경을 더 안전하게 반영했습니다. + +### 3. 검색 결과의 다중 데이터 처리 +검색 기능에서는 음악과 팟캐스트를 각각 조회한 뒤 +두 결과를 하나의 스트림으로 합쳐서 화면에 표시했습니다. + +이 과정을 통해 서로 다른 타입의 데이터를 +하나의 검색 흐름 안에서 함께 보여주는 구조를 구현할 수 있었습니다. + +--- + +## 폴더 구조 + +```bash +Challenge +├── Model +│ ├── MusicModel.swift +│ └── PodcastModel.swift +├── Service +│ └── APIService.swift +├── View +│ ├── View +│ │ ├── CardCell.swift +│ │ ├── ListCell.swift +│ │ ├── SearchCardCell.swift +│ │ └── SectionHeaderView.swift +│ └── ViewController +│ ├── MainViewController.swift +│ └── SearchResultViewController.swift +└── ViewModel + ├── MainViewModel.swift + └── SearchResultViewModel.swift +``` + +--- + +# 트러블 슈팅 +## RxSwift transform 구조 이해 +초기에는 ViewModel의 transform 내부에서 입력을 어떻게 처리하고 어떤 Output을 반환해야 하는지 흐름을 잡는 것이 어려웠습니다. +하지만 Input은 “이벤트의 시작점”, Output은 “화면이 구독할 결과 스트림”이라는 관점으로 정리한 뒤 각 화면의 데이터 흐름을 더 명확하게 설계할 수 있었습니다. + +## 여러 API 결과를 하나의 화면 상태로 합치기 +계절별 음악 데이터나 검색 시 음악/팟캐스트 데이터를 각각 불러온 뒤 이를 어떤 형태로 묶어 화면에 전달할지 고민했습니다. +이 과정에서 merge, scan, combineLatest 같은 연산자를 활용해 여러 비동기 결과를 하나의 상태로 정리하는 방법을 익힐 수 있었습니다.\ + +--- + +# 추가 개선 사항 +- 상세 화면 설계 및 구현 +- 음악 듣기 기능 추가 +- 로딩 / 빈 결과 / 에러 상태 UI 추가 +- 테스트 가능한 ViewModel 구조로 개선 + +--- + +# 스크린샷 + +|메인화면|검색화면| +|:-:|:-:| +|스크린샷 2026-03-19 오전 9 53 03|스크린샷 2026-03-19 오전 9 51 48| + +---