diff --git a/.gitignore b/.gitignore index fb111fa..ad3e2bb 100644 --- a/.gitignore +++ b/.gitignore @@ -131,5 +131,7 @@ iOSInjectionProject/ /*.gcno **/xcshareddata/WorkspaceSettings.xcsettings +*.config +*.xcconfig # End of https://www.toptal.com/developers/gitignore/api/macos,swift,xcode diff --git a/Movie_Combine_MVVM.xcodeproj/project.pbxproj b/Movie_Combine_MVVM.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0715c72 --- /dev/null +++ b/Movie_Combine_MVVM.xcodeproj/project.pbxproj @@ -0,0 +1,400 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 63A50E1F2EF083AC0012FA83 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 63A50E1E2EF083AC0012FA83 /* Then */; }; + 63A50E222EF083B60012FA83 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 63A50E212EF083B60012FA83 /* SnapKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 631184052EEC7B7E00FDF1B3 /* Movie_Combine_MVVM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Movie_Combine_MVVM.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 631184172EEC7B7F00FDF1B3 /* Exceptions for "Movie_Combine_MVVM" folder in "Movie_Combine_MVVM" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + API_Keys.xcconfig, + Info.plist, + ); + target = 631184042EEC7B7E00FDF1B3 /* Movie_Combine_MVVM */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 631184072EEC7B7E00FDF1B3 /* Movie_Combine_MVVM */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 631184172EEC7B7F00FDF1B3 /* Exceptions for "Movie_Combine_MVVM" folder in "Movie_Combine_MVVM" target */, + ); + path = Movie_Combine_MVVM; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 631184022EEC7B7E00FDF1B3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 63A50E1F2EF083AC0012FA83 /* Then in Frameworks */, + 63A50E222EF083B60012FA83 /* SnapKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 631183FC2EEC7B7E00FDF1B3 = { + isa = PBXGroup; + children = ( + 631184072EEC7B7E00FDF1B3 /* Movie_Combine_MVVM */, + 631184062EEC7B7E00FDF1B3 /* Products */, + ); + sourceTree = ""; + }; + 631184062EEC7B7E00FDF1B3 /* Products */ = { + isa = PBXGroup; + children = ( + 631184052EEC7B7E00FDF1B3 /* Movie_Combine_MVVM.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 631184042EEC7B7E00FDF1B3 /* Movie_Combine_MVVM */ = { + isa = PBXNativeTarget; + buildConfigurationList = 631184182EEC7B7F00FDF1B3 /* Build configuration list for PBXNativeTarget "Movie_Combine_MVVM" */; + buildPhases = ( + 631184012EEC7B7E00FDF1B3 /* Sources */, + 631184022EEC7B7E00FDF1B3 /* Frameworks */, + 631184032EEC7B7E00FDF1B3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 631184072EEC7B7E00FDF1B3 /* Movie_Combine_MVVM */, + ); + name = Movie_Combine_MVVM; + packageProductDependencies = ( + 63A50E1E2EF083AC0012FA83 /* Then */, + 63A50E212EF083B60012FA83 /* SnapKit */, + ); + productName = Movie_Combine_MVVM; + productReference = 631184052EEC7B7E00FDF1B3 /* Movie_Combine_MVVM.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 631183FD2EEC7B7E00FDF1B3 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2610; + LastUpgradeCheck = 2620; + TargetAttributes = { + 631184042EEC7B7E00FDF1B3 = { + CreatedOnToolsVersion = 26.1.1; + }; + }; + }; + buildConfigurationList = 631184002EEC7B7E00FDF1B3 /* Build configuration list for PBXProject "Movie_Combine_MVVM" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 631183FC2EEC7B7E00FDF1B3; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 63A50E1D2EF083AC0012FA83 /* XCRemoteSwiftPackageReference "Then" */, + 63A50E202EF083B60012FA83 /* XCRemoteSwiftPackageReference "SnapKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 631184062EEC7B7E00FDF1B3 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 631184042EEC7B7E00FDF1B3 /* Movie_Combine_MVVM */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 631184032EEC7B7E00FDF1B3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 631184012EEC7B7E00FDF1B3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 631184192EEC7B7F00FDF1B3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2QJX795Q3R; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Movie_Combine_MVVM/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + 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 = "iseungjun.Movie-Combine-MVVM"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6311841A2EEC7B7F00FDF1B3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2QJX795Q3R; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Movie_Combine_MVVM/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + 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 = "iseungjun.Movie-Combine-MVVM"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 6311841B2EEC7B7F00FDF1B3 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 631184072EEC7B7E00FDF1B3 /* Movie_Combine_MVVM */; + baseConfigurationReferenceRelativePath = API_Keys.xcconfig; + 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; + DEVELOPMENT_TEAM = 2QJX795Q3R; + 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.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 6311841C2EEC7B7F00FDF1B3 /* 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"; + DEVELOPMENT_TEAM = 2QJX795Q3R; + 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.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 631184002EEC7B7E00FDF1B3 /* Build configuration list for PBXProject "Movie_Combine_MVVM" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6311841B2EEC7B7F00FDF1B3 /* Debug */, + 6311841C2EEC7B7F00FDF1B3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 631184182EEC7B7F00FDF1B3 /* Build configuration list for PBXNativeTarget "Movie_Combine_MVVM" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 631184192EEC7B7F00FDF1B3 /* Debug */, + 6311841A2EEC7B7F00FDF1B3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 63A50E1D2EF083AC0012FA83 /* XCRemoteSwiftPackageReference "Then" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/devxoul/Then.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; + 63A50E202EF083B60012FA83 /* XCRemoteSwiftPackageReference "SnapKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SnapKit/SnapKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.7.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 63A50E1E2EF083AC0012FA83 /* Then */ = { + isa = XCSwiftPackageProductDependency; + package = 63A50E1D2EF083AC0012FA83 /* XCRemoteSwiftPackageReference "Then" */; + productName = Then; + }; + 63A50E212EF083B60012FA83 /* SnapKit */ = { + isa = XCSwiftPackageProductDependency; + package = 63A50E202EF083B60012FA83 /* XCRemoteSwiftPackageReference "SnapKit" */; + productName = SnapKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 631183FD2EEC7B7E00FDF1B3 /* Project object */; +} diff --git a/Movie_Combine_MVVM.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Movie_Combine_MVVM.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Movie_Combine_MVVM.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Movie_Combine_MVVM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Movie_Combine_MVVM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..a2c0a45 --- /dev/null +++ b/Movie_Combine_MVVM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "c4346f38a1062375f708fef53cc6bf388d783cd4f706e82bee303bfde1d89b70", + "pins" : [ + { + "identity" : "snapkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SnapKit/SnapKit.git", + "state" : { + "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", + "version" : "5.7.1" + } + }, + { + "identity" : "then", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devxoul/Then.git", + "state" : { + "revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a", + "version" : "3.0.0" + } + } + ], + "version" : 3 +} diff --git a/Movie_Combine_MVVM/API_Keys.xcconfig b/Movie_Combine_MVVM/API_Keys.xcconfig new file mode 100644 index 0000000..1c0ce16 --- /dev/null +++ b/Movie_Combine_MVVM/API_Keys.xcconfig @@ -0,0 +1,11 @@ +// +// API_Keys.xcconfig +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +// Configuration settings file format documentation can be found at: +// https://developer.apple.com/documentation/xcode/adding-a-build-configuration-file-to-your-project + +MOVIE_API_KEY = dbaa00196d9fc5ea7420000234c5ded3dsadsa diff --git a/Movie_Combine_MVVM/Apps/AppDelegate.swift b/Movie_Combine_MVVM/Apps/AppDelegate.swift new file mode 100644 index 0000000..20cfc50 --- /dev/null +++ b/Movie_Combine_MVVM/Apps/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/13/25. +// + +import UIKit + +@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. + } + + +} + diff --git a/Movie_Combine_MVVM/Apps/Base.lproj/LaunchScreen.storyboard b/Movie_Combine_MVVM/Apps/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Movie_Combine_MVVM/Apps/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movie_Combine_MVVM/Apps/SceneDelegate.swift b/Movie_Combine_MVVM/Apps/SceneDelegate.swift new file mode 100644 index 0000000..54fbd70 --- /dev/null +++ b/Movie_Combine_MVVM/Apps/SceneDelegate.swift @@ -0,0 +1,56 @@ +// +// SceneDelegate.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/13/25. +// + +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 windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = TabBarController() + window?.makeKeyAndVisible() + } + + 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. + } + + +} + diff --git a/Movie_Combine_MVVM/Extensions/Bundle+.swift b/Movie_Combine_MVVM/Extensions/Bundle+.swift new file mode 100644 index 0000000..9b3fa9e --- /dev/null +++ b/Movie_Combine_MVVM/Extensions/Bundle+.swift @@ -0,0 +1,14 @@ +// +// Bundle+.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import Foundation + +extension Bundle { + var movieAPIKey: String? { + return infoDictionary?["MOVIE_API_KEY"] as? String + } +} diff --git a/Movie_Combine_MVVM/Extensions/UITextField+.swift b/Movie_Combine_MVVM/Extensions/UITextField+.swift new file mode 100644 index 0000000..2c6bc96 --- /dev/null +++ b/Movie_Combine_MVVM/Extensions/UITextField+.swift @@ -0,0 +1,20 @@ +// +// UITextField+.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import UIKit +import Combine + +extension UITextField { + + func textDidChangePublisher() -> AnyPublisher { + NotificationCenter.default + .publisher(for: UITextField.textDidChangeNotification, object: self) + .map { _ in self.text ?? "" } + .eraseToAnyPublisher() + } + +} diff --git a/Movie_Combine_MVVM/Extensions/ViewController+.swift b/Movie_Combine_MVVM/Extensions/ViewController+.swift new file mode 100644 index 0000000..78f60ab --- /dev/null +++ b/Movie_Combine_MVVM/Extensions/ViewController+.swift @@ -0,0 +1,22 @@ +// +// ViewController.swift +// Combine-With-UIKit +// +// Created by 이승준 on 6/20/25. +// + +import UIKit + +extension UIViewController { + + func hideKeyboardWhenTappedAround() { + let tap = UITapGestureRecognizer(target: self, action: #selector(UIViewController.dismissKeyboard)) + tap.cancelsTouchesInView = false + view.addGestureRecognizer(tap) + } + + @objc func dismissKeyboard() { + view.endEditing(true) + } + +} diff --git a/Movie_Combine_MVVM/Global/InputOutputViewModelProtocol.swift b/Movie_Combine_MVVM/Global/InputOutputViewModelProtocol.swift new file mode 100644 index 0000000..48f03a5 --- /dev/null +++ b/Movie_Combine_MVVM/Global/InputOutputViewModelProtocol.swift @@ -0,0 +1,15 @@ +// +// InputOutputViewModelProtocol.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import Combine + +protocol InputOutputViewModelProtocol { + associatedtype Input + associatedtype Output + + func transform(input: AnyPublisher) -> AnyPublisher +} diff --git a/Movie_Combine_MVVM/Global/MovieViewModel.swift b/Movie_Combine_MVVM/Global/MovieViewModel.swift new file mode 100644 index 0000000..c10c244 --- /dev/null +++ b/Movie_Combine_MVVM/Global/MovieViewModel.swift @@ -0,0 +1,144 @@ +// +// MovieViewModel.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import Foundation +import Combine + +final class MovieViewModel: InputOutputViewModelProtocol { + + enum Input { + case hitHomeViewBottom + case hitSearchViewBottom(String) + case viewDidLoad + case search(String) + } + + enum Output { + case dataFetched + } + + private let output: PassthroughSubject = .init() + private var cancellables = Set() + + var people: [People] = [] + var movies: [Movie] = [] + private var isSearchFetching: Bool = false + private var isPeopleFetching: Bool = false + + private var peopleCurrentPage: Int = 1 + private var searchCurrentPage: Int = 1 + private let size: Int = 10 + + func transform(input: AnyPublisher) -> AnyPublisher { + + // 1. ViewDidLoad 처리 (즉시 실행) + input + .filter { if case .viewDidLoad = $0 { return true } else { return false } } + .sink { [weak self] _ in + self?.fetchPeopleData() + } + .store(in: &cancellables) + + // 2. 검색어 입력 처리 (Debounce 적용) + // - 사용자가 타자를 칠 때는 기다렸다가, 멈추면(0.5초) API 호출 + input + .compactMap { input -> String? in + guard case let .search(keyword) = input else { return nil } + return keyword + } + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ✅ Debounce + .removeDuplicates() // 같은 검색어 연속 호출 방지 + .sink { [weak self] keyword in + self?.searchCurrentPage = 1 // 검색시 페이지 초기화 로직 + self?.searchMovieData(keyword: keyword) + } + .store(in: &cancellables) + + // 3. 홈 화면 바닥 감지 (Throttle 적용) + // - 스크롤을 마구 내려도 0.3초에 한 번만 트리거 + input + .filter { if case .hitHomeViewBottom = $0 { return true } else { return false } } + .throttle(for: .milliseconds(300), scheduler: RunLoop.main, latest: false) // ✅ Throttle + .sink { [weak self] _ in + self?.fetchPeopleData() + } + .store(in: &cancellables) + + // 4. 검색 화면 바닥 감지 (Throttle 적용) + input + .compactMap { input -> String? in + guard case let .hitSearchViewBottom(keyword) = input else { return nil } + return keyword + } + .throttle(for: .milliseconds(300), scheduler: RunLoop.main, latest: false) // ✅ Throttle + .sink { [weak self] keyword in + guard let self = self, !keyword.isEmpty else { return } + self.searchMovieData(keyword: keyword) + } + .store(in: &cancellables) + + return output.eraseToAnyPublisher() + } + + private func fetchPeopleData() { + guard !isPeopleFetching else { return } + guard let apiKey = Bundle.main.movieAPIKey else { return } + isPeopleFetching = true + URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.kobis.or.kr/kobisopenapi/webservice/rest/people/searchPeopleList.json?key=\(apiKey)&curPage=\(peopleCurrentPage)&itemPerPage=\(size)")!) + .map(\.data) + .decode(type: PeopleListResponse.self, decoder: JSONDecoder()) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + print("PeopleListResponse failed: \(error)") + case .finished: + break + } + self.isPeopleFetching = false + }, receiveValue: { [weak self] response in + guard let self = self else { return } + people.append(contentsOf: response.peopleListResult.peopleList) + output.send(.dataFetched) + peopleCurrentPage += 1 + isPeopleFetching = false + }) + .store(in: &cancellables) + } + + private func searchMovieData(keyword: String) { + guard !isSearchFetching else { return } + guard let apiKey = Bundle.main.movieAPIKey else { return } + isSearchFetching = true + URLSession.shared + .dataTaskPublisher(for: URL(string: "https://www.kobis.or.kr/kobisopenapi/webservice/rest/movie/searchMovieList.json?key=\(apiKey)&curPage=\(searchCurrentPage)&itemPerPage=\(size)&movieNm=\(keyword)")! + ) + .map(\.data) + .decode(type: MovieListResponse.self, decoder: JSONDecoder()) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + print("MovieListResponse failed: \(error)") + case .finished: + break + } + self.isSearchFetching = false + }, receiveValue: { [weak self] response in + guard let self = self else { return } + if searchCurrentPage == 1 { + movies = response.movieListResult.movieList + } else { + movies.append(contentsOf: response.movieListResult.movieList) + } + output.send(.dataFetched) + searchCurrentPage += 1 + isSearchFetching = false + }) + .store(in: &cancellables) + } +} diff --git a/Movie_Combine_MVVM/Info.plist b/Movie_Combine_MVVM/Info.plist new file mode 100644 index 0000000..2af65b6 --- /dev/null +++ b/Movie_Combine_MVVM/Info.plist @@ -0,0 +1,25 @@ + + + + + MOVIE_API_KEY + $(MOVIE_API_KEY) + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Movie_Combine_MVVM/Models/DTO/Movie_DTO.swift b/Movie_Combine_MVVM/Models/DTO/Movie_DTO.swift new file mode 100644 index 0000000..368bd75 --- /dev/null +++ b/Movie_Combine_MVVM/Models/DTO/Movie_DTO.swift @@ -0,0 +1,61 @@ +// +// Movie-DTO.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import Foundation + +struct MovieListResponse: Codable { + let movieListResult: MovieListResult +} + +struct MovieListResult: Codable { + let totCnt: Int + let source: String + let movieList: [Movie] +} + +struct Movie: Codable { + let movieCd: String + let movieNm: String + let movieNmEn: String? + let prdtYear: String + let openDt: String + let typeNm: String + let prdtStatNm: String + let nationAlt: String + let genreAlt: String + let repNationNm: String + let repGenreNm: String + let directors: [Director] + let companys: [Company] + + var bookMark: Bool = false + + enum CodingKeys: CodingKey { + case movieCd + case movieNm + case movieNmEn + case prdtYear + case openDt + case typeNm + case prdtStatNm + case nationAlt + case genreAlt + case repNationNm + case repGenreNm + case directors + case companys + } +} + +struct Director: Codable { + let peopleNm: String +} + +struct Company: Codable { + let companyCd: String + let companyNm: String +} diff --git a/Movie_Combine_MVVM/Models/DTO/People_DTO.swift b/Movie_Combine_MVVM/Models/DTO/People_DTO.swift new file mode 100644 index 0000000..3a48095 --- /dev/null +++ b/Movie_Combine_MVVM/Models/DTO/People_DTO.swift @@ -0,0 +1,26 @@ +// +// People_DTO.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import Foundation + +struct PeopleListResponse: Codable { + let peopleListResult: PeopleListResult +} + +struct PeopleListResult: Codable { + let totCnt: Int + let source: String + let peopleList: [People] +} + +struct People: Codable { + let peopleCd: String + let peopleNm: String + let peopleNmEn: String + let repRoleNm: String + let filmoNames: String +} diff --git a/Movie_Combine_MVVM/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Movie_Combine_MVVM/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Movie_Combine_MVVM/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Movie_Combine_MVVM/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Movie_Combine_MVVM/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Movie_Combine_MVVM/Resources/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/Movie_Combine_MVVM/Resources/Assets.xcassets/Contents.json b/Movie_Combine_MVVM/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Movie_Combine_MVVM/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Movie_Combine_MVVM/ViewControllers/Home/HomeCollectionViewCell.swift b/Movie_Combine_MVVM/ViewControllers/Home/HomeCollectionViewCell.swift new file mode 100644 index 0000000..4d1f132 --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/Home/HomeCollectionViewCell.swift @@ -0,0 +1,63 @@ +// +// HomeCollectionViewCell.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import UIKit + +import Then +import SnapKit + +final class HomeCollectionViewCell: UICollectionViewCell { + + static let identifier: String = "HomeCollectionViewCell" + + let peopleNmLabel = UILabel().then { + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let roleNmLabel = UILabel().then { + $0.textAlignment = .right + $0.font = .systemFont(ofSize: 14, weight: .regular) + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(peopleNmLabel) + self.addSubview(roleNmLabel) + + peopleNmLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().offset(16) + make.trailing.equalTo(roleNmLabel.snp.leading).offset(-10) + } + + roleNmLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().offset(-16) + make.width.equalTo(50) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(data: People, index: Int) { + switch (index / 10 ) % 3 { + case 0: + peopleNmLabel.textColor = .systemPink + case 1: + peopleNmLabel.textColor = .systemCyan + case 2: + peopleNmLabel.textColor = .systemGreen + default: + peopleNmLabel.textColor = .white + } + peopleNmLabel.text = String(index + 1) + ": " + data.peopleNm + roleNmLabel.text = data.repRoleNm + } + +} diff --git a/Movie_Combine_MVVM/ViewControllers/Home/HomeView.swift b/Movie_Combine_MVVM/ViewControllers/Home/HomeView.swift new file mode 100644 index 0000000..2616506 --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/Home/HomeView.swift @@ -0,0 +1,42 @@ +// +// MainView.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/16/25. +// + +import UIKit + +import Then +import SnapKit + +final class HomeView: UIView { + + let collectionView: UICollectionView = { + let collection = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collection.register(HomeCollectionViewCell.self, + forCellWithReuseIdentifier: HomeCollectionViewCell.identifier) + return collection + }() + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(collectionView) + collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setCollectionViewLayout() { + let flowLayout = UICollectionViewFlowLayout() + let cellWidth: CGFloat = self.bounds.width + flowLayout.itemSize = CGSize(width: cellWidth, height: 100) + flowLayout.minimumLineSpacing = 10 + flowLayout.minimumInteritemSpacing = 0 + self.collectionView.setCollectionViewLayout(flowLayout, animated: false) + } +} diff --git a/Movie_Combine_MVVM/ViewControllers/Home/HomeViewController.swift b/Movie_Combine_MVVM/ViewControllers/Home/HomeViewController.swift new file mode 100644 index 0000000..0363f2a --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/Home/HomeViewController.swift @@ -0,0 +1,97 @@ +// +// ViewController.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/13/25. +// + +import UIKit +import Combine + +import Then +import SnapKit + +class HomeViewController: UIViewController { + + private let homeView = HomeView() + private let viewModel = MovieViewModel() + + private var throttleWorkItem: DispatchWorkItem? + + private let inputSubject = PassthroughSubject() + private var cancellables: Set = [] + + private let scrollEventSubject = PassthroughSubject() + + override func viewDidLoad() { + super.viewDidLoad() + self.view = homeView + homeView.collectionView.delegate = self + homeView.collectionView.dataSource = self + bindViewModel() + inputSubject.send(.viewDidLoad) + + scrollEventSubject + .throttle(for: .seconds(0.3), scheduler: RunLoop.main, latest: false) + .sink { [weak self] _ in + guard let self = self else { return } + inputSubject.send(.hitHomeViewBottom) + } + .store(in: &cancellables) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + homeView.setCollectionViewLayout() + } + + func bindViewModel() { + let output = viewModel.transform(input: inputSubject.eraseToAnyPublisher()) + + output + .receive(on: DispatchQueue.main) + .sink{ [weak self] output in + guard let self = self else { return } + switch output { + case .dataFetched: + self.homeView.collectionView.reloadData() + } + } + .store(in: &cancellables) + } +} + +extension HomeViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return viewModel.people.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeCollectionViewCell.identifier, for: indexPath) as? HomeCollectionViewCell + else { + return UICollectionViewCell() + } + let data = viewModel.people[indexPath.row] + cell.configure(data: data, index: indexPath.row) + return cell + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let height = scrollView.frame.size.height + + if offsetY > contentHeight - height - 100 { + scrollEventSubject.send() + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + } +} + +#Preview { + HomeViewController() +} diff --git a/Movie_Combine_MVVM/ViewControllers/SSE/SSEViewController.swift b/Movie_Combine_MVVM/ViewControllers/SSE/SSEViewController.swift new file mode 100644 index 0000000..e160c64 --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/SSE/SSEViewController.swift @@ -0,0 +1,108 @@ +// +// SSEViewController.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/29/25. +// + +import UIKit + +// 위키미디어 데이터 구조체 (필요한 필드만 정의) +struct WikiEvent: Codable { + let title: String + let user: String + let comment: String? +} + +class SSEViewController: UIViewController { + + let url = URL(string: "https://stream.wikimedia.org/v2/stream/recentchange")! + + // UI 요소 + private let titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.font = .boldSystemFont(ofSize: 18) + label.text = "연결 대기 중..." + return label + }() + + private let detailLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.font = .systemFont(ofSize: 14) + label.textColor = .darkGray + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + setupUI() + startStreaming() + } + + private func setupUI() { + let stackView = UIStackView(arrangedSubviews: [titleLabel, detailLabel]) + stackView.axis = .vertical + stackView.spacing = 20 + stackView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + private func startStreaming() { + let configuration = URLSessionConfiguration.default + // SSE를 위해 타임아웃을 무한정으로 설정 (선택 사항) + configuration.timeoutIntervalForRequest = TimeInterval(Int.max) // 추가적인 데이터가 들어올 때까지 기다려주는 최대 시간 + configuration.timeoutIntervalForResource = TimeInterval(Int.max) // 전체 리소스를 다운로드하는 데 허용되는 최대 시간 + + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + let task = session.dataTask(with: url) + task.resume() + } +} + +// MARK: - URLSessionDataDelegate +extension SSEViewController: URLSessionDataDelegate { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + // 1. 데이터를 문자열로 변환 + guard let string = String(data: data, encoding: .utf8) else { return } + + // 2. SSE 데이터 형식에서 "data:" 접두어 이후 내용 추출 + let lines = string.components(separatedBy: "\n") + for line in lines { + if line.hasPrefix("data:") { + let jsonString = line.replacingOccurrences(of: "data:", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + + guard let jsonData = jsonString.data(ofValue: .utf8) else { continue } + + // 3. Decoding 및 UI 업데이트 + do { + let event = try JSONDecoder().decode(WikiEvent.self, from: jsonData) + + DispatchQueue.main.async { + self.titleLabel.text = "수정된 문서: \(event.title)" + self.detailLabel.text = "사용자: \(event.user)\n설명: \(event.comment ?? "없음")" + } + } catch { + print("Decoding Error: \(error)") + } + } + } + } +} + +// String 편의 확장 +extension String { + func data(ofValue encoding: String.Encoding) -> Data? { + return self.data(using: encoding) + } +} diff --git a/Movie_Combine_MVVM/ViewControllers/Search/SearchMovieCollectionViewCell.swift b/Movie_Combine_MVVM/ViewControllers/Search/SearchMovieCollectionViewCell.swift new file mode 100644 index 0000000..60a5043 --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/Search/SearchMovieCollectionViewCell.swift @@ -0,0 +1,64 @@ +// +// SearchMovieCollectionViewCell.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import UIKit + +import Then +import SnapKit + +final class SearchMovieCollectionViewCell: UICollectionViewCell { + + static let identifier: String = "SearchMovieCollectionViewCell" + + let movieNmLabel = UILabel().then { + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let yearLabel = UILabel().then { + $0.textAlignment = .right + $0.font = .systemFont(ofSize: 14, weight: .regular) + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(movieNmLabel) + self.addSubview(yearLabel) + + movieNmLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().offset(16) + make.trailing.equalTo(yearLabel.snp.leading).offset(-10) + } + + yearLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().offset(-16) + make.width.equalTo(50) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(data: Movie, index: Int) { + switch (index / 10 ) % 3 { + case 0: + movieNmLabel.textColor = .systemPink + case 1: + movieNmLabel.textColor = .systemCyan + case 2: + movieNmLabel.textColor = .systemGreen + default: + movieNmLabel.textColor = .white + } + movieNmLabel.text = String(index + 1) + ": " + data.movieNm + yearLabel.text = data.prdtYear + } + +} + diff --git a/Movie_Combine_MVVM/ViewControllers/Search/SearchView.swift b/Movie_Combine_MVVM/ViewControllers/Search/SearchView.swift new file mode 100644 index 0000000..a01c35b --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/Search/SearchView.swift @@ -0,0 +1,63 @@ +// +// SearchView.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import UIKit +import Combine + +import Then +import SnapKit + +final class SearchView: UIView { + + let searchBar = UITextField().then { + $0.isUserInteractionEnabled = true + $0.placeholder = "검색어를 입력하세요" + $0.clipsToBounds = true + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 2 + $0.layer.borderColor = UIColor.white.cgColor + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let collectionView: UICollectionView = { + let collection = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collection.register(SearchMovieCollectionViewCell.self, + forCellWithReuseIdentifier: SearchMovieCollectionViewCell.identifier) + return collection + }() + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(searchBar) + self.addSubview(collectionView) + + searchBar.snp.makeConstraints { make in + make.height.equalTo(60) + make.leading.trailing.equalToSuperview().inset(20) + make.top.equalToSuperview().offset(100) + } + + collectionView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setCollectionViewLayout() { + let flowLayout = UICollectionViewFlowLayout() + let cellWidth: CGFloat = self.bounds.width + flowLayout.itemSize = CGSize(width: cellWidth, height: 100) + flowLayout.minimumLineSpacing = 10 + flowLayout.minimumInteritemSpacing = 0 + self.collectionView.setCollectionViewLayout(flowLayout, animated: false) + } +} + diff --git a/Movie_Combine_MVVM/ViewControllers/Search/SearchViewController.swift b/Movie_Combine_MVVM/ViewControllers/Search/SearchViewController.swift new file mode 100644 index 0000000..509be98 --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/Search/SearchViewController.swift @@ -0,0 +1,108 @@ +// +// SearchViewController.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + +import UIKit +import Combine + +import Then +import SnapKit + +class SearchViewController: UIViewController { + + private let searchView = SearchView() + private let viewModel = MovieViewModel() + + private var throttleWorkItem: DispatchWorkItem? + + private let inputSubject = PassthroughSubject() + private var cancellables: Set = [] + + private let scrollEventSubject = PassthroughSubject() + + override func viewDidLoad() { + super.viewDidLoad() + self.view = searchView + searchView.collectionView.delegate = self + searchView.collectionView.dataSource = self + bindViewModel() + hideKeyboardWhenTappedAround() + inputSubject.send(.viewDidLoad) + + searchView.searchBar.textDidChangePublisher() + .debounce(for: .seconds(0.3), scheduler: RunLoop.main) + .sink { [weak self] keyword in + guard let self else { return } + self.inputSubject.send(.search(keyword)) + self.searchView.collectionView.reloadData() + } + .store(in: &cancellables) + + scrollEventSubject + .throttle(for: .seconds(0.3), scheduler: RunLoop.main, latest: false) + .sink { [weak self] _ in + guard let self = self else { return } + inputSubject.send(.hitSearchViewBottom(searchView.searchBar.text ?? "")) + } + .store(in: &cancellables) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + searchView.setCollectionViewLayout() + } + + func bindViewModel() { + let output = viewModel.transform(input: inputSubject.eraseToAnyPublisher()) + + output + .receive(on: DispatchQueue.main) + .sink{ [weak self] output in + guard let self = self else { return } + switch output { + case .dataFetched: + self.searchView.collectionView.reloadData() + } + } + .store(in: &cancellables) + } +} + +extension SearchViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return viewModel.movies.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: SearchMovieCollectionViewCell.identifier, for: indexPath) as? SearchMovieCollectionViewCell + else { + return UICollectionViewCell() + } + let data = viewModel.movies[indexPath.row] + cell.configure(data: data, index: indexPath.row) + return cell + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let height = scrollView.frame.size.height + + if offsetY > contentHeight - height - 100 { + scrollEventSubject.send() + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + } +} + + +#Preview { + SearchViewController() +} diff --git a/Movie_Combine_MVVM/ViewControllers/TabBar/TabBar.swift b/Movie_Combine_MVVM/ViewControllers/TabBar/TabBar.swift new file mode 100644 index 0000000..336b311 --- /dev/null +++ b/Movie_Combine_MVVM/ViewControllers/TabBar/TabBar.swift @@ -0,0 +1,36 @@ +// +// TabBar.swift +// Movie_Combine_MVVM +// +// Created by 이승준 on 12/17/25. +// + + +import UIKit + +class TabBarController: UITabBarController, UITabBarControllerDelegate { + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + // Do any additional setup after loading the view. + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + let tabOne = UINavigationController(rootViewController:HomeViewController()) + let tabOneBarItem = UITabBarItem(title: "홈 화면", image: UIImage(systemName: "house"), tag: 0) + tabOne.tabBarItem = tabOneBarItem + + let tabTwo = UINavigationController(rootViewController: SearchViewController()) + let tabTwoBarItem = UITabBarItem(title: "영화 검색", image: UIImage(systemName: "magnifyingglass"), tag: 1) + tabTwo.tabBarItem = tabTwoBarItem + + let tabThree = UINavigationController(rootViewController: SSEViewController()) + let tabThreeBarItem = UITabBarItem(title: "SSE", image: UIImage(systemName: "globe"), tag: 1) + tabThree.tabBarItem = tabThreeBarItem + + self.viewControllers = [tabOne, tabTwo, tabThree] + } +}