๐ผ๏ธ ExtensibleImage
โญ ๋์ด๋: โญโญ
โฑ๏ธ ์์ ์๊ฐ: 1-2h
๐ iOS 26
๋ ์ด์ด ๊ธฐ๋ฐ ํ์ฅ ๊ฐ๋ฅํ ์ด๋ฏธ์ง ํฌ๋งท
iOS 18+๐ 2024
โจ ExtensibleImage๋?
ExtensibleImage๋ iOS 18์์ ๋์ ๋ ์๋ก์ด ์ด๋ฏธ์ง ํฌ๋งท์ผ๋ก, ๋ ์ด์ด ๊ธฐ๋ฐ ํธ์ง, ๋ฉํ๋ฐ์ดํฐ ๋ณด์กด, ๋นํ๊ดด ํธ์ง์ ์ง์ํฉ๋๋ค. Photoshop์ PSD์ฒ๋ผ ์ฌ๋ฌ ๋ ์ด์ด๋ฅผ ํฌํจํ ์ ์์ผ๋ฉฐ, Core Image ํํฐ์ ์๋ฒฝํ๊ฒ ํตํฉ๋ฉ๋๋ค.
๐ก ํต์ฌ ๊ธฐ๋ฅ: ๋ ์ด์ด ๊ธฐ๋ฐ ๊ตฌ์กฐ ยท ๋นํ๊ดด ํธ์ง ยท ๋ฉํ๋ฐ์ดํฐ ๋ณด์กด ยท Core Image ํตํฉ ยท ๊ณ ํ์ง ์ถ๋ ฅ ยท ํจ์จ์ ์ธ ์ ์ฅ
๐ฏ 1. ๊ธฐ๋ณธ ์ค์
ExtensibleImage๋ฅผ ์์ฑํ๊ณ ๊ธฐ๋ณธ ์์ ์ ์ํํฉ๋๋ค.
ImageManager.swift โ ๊ธฐ๋ณธ ์ค์
import UIKit import ExtensibleImage import CoreImage @Observable class ExtensibleImageManager { var currentImage: EIImage? // ์ ์ด๋ฏธ์ง ์์ฑ func createNewImage(size: CGSize) { // ๋น ์บ๋ฒ์ค ์์ฑ currentImage = EIImage( size: size, colorSpace: CGColorSpace(name: CGColorSpace.sRGB)! ) } // UIImage์์ ์์ฑ func createFromUIImage(_ uiImage: UIImage) { guard let cgImage = uiImage.cgImage else { return } currentImage = EIImage(cgImage: cgImage) } // ํ์ผ์์ ๋ก๋ func loadImage(from url: URL) throws { currentImage = try EIImage(contentsOf: url) } // ํ์ผ๋ก ์ ์ฅ func saveImage(to url: URL) throws { guard let image = currentImage else { return } try image.write(to: url, options: [ .compressionQuality: 0.9, .preserveMetadata: true ]) } // UIImage๋ก ๋ณํ (ํ๋ฉด ํ์์ฉ) func toUIImage() -> UIImage? { guard let image = currentImage else { return nil } return UIImage(eiImage: image) } }
๐ 2. ๋ ์ด์ด ๊ด๋ฆฌ
์ฌ๋ฌ ๋ ์ด์ด๋ฅผ ์์ฑํ๊ณ ๊ด๋ฆฌํฉ๋๋ค.
LayerManager.swift โ ๋ ์ด์ด ๊ด๋ฆฌ
import ExtensibleImage @Observable class LayerManager { var image: EIImage init(size: CGSize) { self.image = EIImage( size: size, colorSpace: CGColorSpace(name: CGColorSpace.sRGB)! ) } // ์ ๋ ์ด์ด ์ถ๊ฐ func addLayer(name: String, content: UIImage) { guard let cgImage = content.cgImage else { return } let layer = EILayer( name: name, content: cgImage, blendMode: .normal, opacity: 1.0 ) image.addLayer(layer) } // ํ ์คํธ ๋ ์ด์ด ์ถ๊ฐ func addTextLayer(text: String, font: UIFont, color: UIColor) { // ํ ์คํธ๋ฅผ ์ด๋ฏธ์ง๋ก ๋ ๋๋ง let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: color ] let textSize = (text as NSString).size(withAttributes: attributes) let renderer = UIGraphicsImageRenderer(size: textSize) let textImage = renderer.image { context in (text as NSString).draw(at: .zero, withAttributes: attributes) } guard let cgImage = textImage.cgImage else { return } let layer = EILayer( name: "Text: \(text)", content: cgImage, blendMode: .normal, opacity: 1.0 ) image.addLayer(layer) } // ๋ ์ด์ด ๋ถํฌ๋ช ๋ ์กฐ์ func setLayerOpacity(at index: Int, opacity: Float) { guard index < image.layers.count else { return } image.layers[index].opacity = opacity } // ๋ ์ด์ด ๋ธ๋ ๋ ๋ชจ๋ ๋ณ๊ฒฝ func setLayerBlendMode(at index: Int, mode: CGBlendMode) { guard index < image.layers.count else { return } image.layers[index].blendMode = mode } // ๋ ์ด์ด ์ญ์ func removeLayer(at index: Int) { guard index < image.layers.count else { return } image.removeLayer(at: index) } // ๋ ์ด์ด ์์ ๋ณ๊ฒฝ func moveLayer(from: Int, to: Int) { guard from < image.layers.count, to < image.layers.count else { return } let layer = image.layers.remove(at: from) image.layers.insert(layer, at: to) } }
๐จ 3. Core Image ํํฐ ํตํฉ
Core Image ํํฐ๋ฅผ ๋ ์ด์ด์ ์ ์ฉํฉ๋๋ค.
FilterManager.swift โ ํํฐ ์ ์ฉ
import CoreImage import ExtensibleImage @Observable class FilterManager { private let context = CIContext() // ๋ธ๋ฌ ํํฐ ์ ์ฉ func applyBlur(to layer: EILayer, radius: Double) -> EILayer { let ciImage = CIImage(cgImage: layer.content) let filter = CIFilter.gaussianBlur() filter.inputImage = ciImage filter.radius = Float(radius) guard let output = filter.outputImage, let cgImage = context.createCGImage(output, from: output.extent) else { return layer } return EILayer( name: layer.name, content: cgImage, blendMode: layer.blendMode, opacity: layer.opacity ) } // ์์ ์กฐ์ ํํฐ func applyColorAdjustment( to layer: EILayer, brightness: Double = 0, contrast: Double = 1, saturation: Double = 1 ) -> EILayer { let ciImage = CIImage(cgImage: layer.content) let filter = CIFilter.colorControls() filter.inputImage = ciImage filter.brightness = Float(brightness) filter.contrast = Float(contrast) filter.saturation = Float(saturation) guard let output = filter.outputImage, let cgImage = context.createCGImage(output, from: output.extent) else { return layer } return EILayer( name: layer.name, content: cgImage, blendMode: layer.blendMode, opacity: layer.opacity ) } // ๋น๋คํธ ํจ๊ณผ func applyVignette(to layer: EILayer, intensity: Double = 1) -> EILayer { let ciImage = CIImage(cgImage: layer.content) let filter = CIFilter.vignette() filter.inputImage = ciImage filter.intensity = Float(intensity) guard let output = filter.outputImage, let cgImage = context.createCGImage(output, from: output.extent) else { return layer } return EILayer( name: layer.name, content: cgImage, blendMode: layer.blendMode, opacity: layer.opacity ) } // ํ๋ฐฑ ๋ณํ func applyMonochrome(to layer: EILayer) -> EILayer { let ciImage = CIImage(cgImage: layer.content) let filter = CIFilter.photoEffectMono() filter.inputImage = ciImage guard let output = filter.outputImage, let cgImage = context.createCGImage(output, from: output.extent) else { return layer } return EILayer( name: layer.name, content: cgImage, blendMode: layer.blendMode, opacity: layer.opacity ) } }
๐พ 4. ๋ฉํ๋ฐ์ดํฐ ๊ด๋ฆฌ
์ด๋ฏธ์ง ๋ฉํ๋ฐ์ดํฐ๋ฅผ ๋ณด์กดํ๊ณ ํธ์งํฉ๋๋ค.
MetadataManager.swift โ ๋ฉํ๋ฐ์ดํฐ ๊ด๋ฆฌ
import ExtensibleImage import ImageIO @Observable class MetadataManager { // ๋ฉํ๋ฐ์ดํฐ ์ฝ๊ธฐ func readMetadata(from image: EIImage) -> [String: Any] { return image.metadata ?? [:] } // ๋ฉํ๋ฐ์ดํฐ ์ถ๊ฐ/์์ func setMetadata(_ metadata: [String: Any], to image: EIImage) { image.metadata = metadata } // GPS ์ ๋ณด ์ถ๊ฐ func addGPSMetadata( to image: EIImage, latitude: Double, longitude: Double ) { var metadata = image.metadata ?? [:] metadata["GPS"] = [ kCGImagePropertyGPSLatitude: latitude, kCGImagePropertyGPSLongitude: longitude ] image.metadata = metadata } // EXIF ์ ๋ณด ์ถ๊ฐ func addEXIFMetadata( to image: EIImage, aperture: Double, exposureTime: Double, iso: Int ) { var metadata = image.metadata ?? [:] metadata["EXIF"] = [ kCGImagePropertyExifApertureValue: aperture, kCGImagePropertyExifExposureTime: exposureTime, kCGImagePropertyExifISOSpeedRatings: [iso] ] image.metadata = metadata } // ์ปค์คํ ๋ฉํ๋ฐ์ดํฐ func addCustomMetadata( to image: EIImage, key: String, value: Any ) { var metadata = image.metadata ?? [:] metadata[key] = value image.metadata = metadata } // ํธ์ง ํ์คํ ๋ฆฌ ์ถ๊ฐ func addEditHistory(to image: EIImage, edit: String) { var metadata = image.metadata ?? [:] var history = (metadata["EditHistory"] as? [String]) ?? [] history.append("\(Date()): \(edit)") metadata["EditHistory"] = history image.metadata = metadata } }
โ๏ธ 5. ๋นํ๊ดด ํธ์ง
์๋ณธ์ ๋ณด์กดํ๋ฉด์ ํธ์งํ๋ ๋นํ๊ดด ํธ์ง ์์คํ ์ ๋๋ค.
NonDestructiveEditor.swift โ ๋นํ๊ดด ํธ์ง
import ExtensibleImage struct EditOperation: Codable { enum OperationType: String, Codable { case brightness, contrast, saturation, blur, crop } let id: UUID let type: OperationType let parameters: [String: Double] let timestamp: Date } @Observable class NonDestructiveEditor { private var originalImage: EIImage private var editHistory: [EditOperation] = [] var currentImage: EIImage init(image: EIImage) { self.originalImage = image self.currentImage = image } // ํธ์ง ์ ์ฉ (ํ์คํ ๋ฆฌ ์ ์ฅ) func applyEdit(_ operation: EditOperation) { editHistory.append(operation) reapplyAllEdits() } // ์คํ ์ทจ์ func undo() { guard !editHistory.isEmpty else { return } editHistory.removeLast() reapplyAllEdits() } // ์๋ณธ์ผ๋ก ๋ณต์ func resetToOriginal() { editHistory.removeAll() currentImage = originalImage } // ๋ชจ๋ ํธ์ง ์ฌ์ ์ฉ private func reapplyAllEdits() { currentImage = originalImage for operation in editHistory { switch operation.type { case .brightness: if let value = operation.parameters["value"] { applyBrightnessFilter(value: value) } case .contrast: if let value = operation.parameters["value"] { applyContrastFilter(value: value) } case .saturation: if let value = operation.parameters["value"] { applySaturationFilter(value: value) } case .blur: if let radius = operation.parameters["radius"] { applyBlurFilter(radius: radius) } case .crop: // ํฌ๋กญ ๋ก์ง break } } } // ํํฐ ์ ์ฉ ํฌํผ ๋ฉ์๋ private func applyBrightnessFilter(value: Double) { // Core Image ํํฐ ์ ์ฉ } private func applyContrastFilter(value: Double) { // Core Image ํํฐ ์ ์ฉ } private func applySaturationFilter(value: Double) { // Core Image ํํฐ ์ ์ฉ } private func applyBlurFilter(radius: Double) { // Core Image ํํฐ ์ ์ฉ } // ํธ์ง ํ์คํ ๋ฆฌ ๋ด๋ณด๋ด๊ธฐ func exportEditHistory() -> Data? { try? JSONEncoder().encode(editHistory) } }
๐ฑ SwiftUI ํตํฉ
ImageEditorView.swift โ ์ข
ํฉ ์์
import SwiftUI import PhotosUI struct ImageEditorView: View { @State private var imageManager = ExtensibleImageManager() @State private var layerManager: LayerManager? @State private var filterManager = FilterManager() @State private var selectedPhoto: PhotosPickerItem? @State private var displayImage: UIImage? var body: some View { NavigationStack { VStack { if let image = displayImage { Image(uiImage: image) .resizable() .scaledToFit() .frame(maxHeight: 400) } else { ContentUnavailableView( "์ด๋ฏธ์ง ์์", systemImage: "photo", description: Text("์ฌ์ง์ ์ ํํ์ธ์") ) } if let manager = layerManager { List { Section("๋ ์ด์ด") { ForEach(manager.image.layers.indices, id: \.self) { index in HStack { Text(manager.image.layers[index].name) Spacer() Text("\(Int(manager.image.layers[index].opacity * 100))%") .foregroundStyle(.secondary) } } } Section("ํํฐ") { Button("๋ธ๋ฌ ํจ๊ณผ") { applyBlur() } Button("ํ๋ฐฑ ๋ณํ") { applyMonochrome() } } } .frame(height: 300) } } .navigationTitle("ExtensibleImage") .toolbar { PhotosPicker(selection: $selectedPhoto, matching: .images) { Label("์ฌ์ง ์ ํ", systemImage: "photo") } } .onChange(of: selectedPhoto) { oldValue, newValue in Task { await loadPhoto(newValue) } } } } private func loadPhoto(_ item: PhotosPickerItem?) async { guard let item, let data = try? await item.loadTransferable(type: Data.self), let uiImage = UIImage(data: data) else { return } imageManager.createFromUIImage(uiImage) layerManager = LayerManager(size: uiImage.size) displayImage = uiImage } private func applyBlur() { guard let manager = layerManager, !manager.image.layers.isEmpty else { return } let layer = manager.image.layers[0] let filtered = filterManager.applyBlur(to: layer, radius: 10) manager.image.layers[0] = filtered displayImage = imageManager.toUIImage() } private func applyMonochrome() { guard let manager = layerManager, !manager.image.layers.isEmpty else { return } let layer = manager.image.layers[0] let filtered = filterManager.applyMonochrome(to: layer) manager.image.layers[0] = filtered displayImage = imageManager.toUIImage() } }
๐ก HIG ๊ฐ์ด๋๋ผ์ธ
- ๋นํ๊ดด ํธ์ง: ์๋ณธ์ ํญ์ ๋ณด์กดํ๊ณ ์ธ์ ๋ ๋ณต์ ๊ฐ๋ฅํ๊ฒ
- ์ฑ๋ฅ: ๋ ์ด์ด๊ฐ ๋ง์ ๋ ๋ ๋๋ง ์ฑ๋ฅ ์ต์ ํ
- ๋ฉํ๋ฐ์ดํฐ: EXIF, GPS ๋ฑ ์ค์ํ ๋ฉํ๋ฐ์ดํฐ ๋ณด์กด
- ์ ์ฅ: ๊ณ ํ์ง ์ต์ ์ ๊ณต, ์์ถ ์์ค ์ ํ ๊ฐ๋ฅ
- ๋ฉ๋ชจ๋ฆฌ: ํฐ ์ด๋ฏธ์ง ์ฒ๋ฆฌ ์ ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ ์ฃผ์
๐ฏ ์ค๋ฌด ํ์ฉ
- ์ฌ์ง ํธ์ง ์ฑ: ๋ ์ด์ด ๊ธฐ๋ฐ ๋นํ๊ดด ํธ์ง
- ๋์์ธ ๋๊ตฌ: ํ ์คํธ, ๋ํ, ์ด๋ฏธ์ง ๋ ์ด์ด ๊ฒฐํฉ
- ์์ ๋ฏธ๋์ด: ํํฐ์ ์ค๋ฒ๋ ์ด ์ ์ฉ
- ์๋ฃ ์์: ๋ฉํ๋ฐ์ดํฐ ๋ณด์กด์ด ์ค์ํ ์ด๋ฏธ์ง
- ์ ๋ฌธ ์ฌ์ง: EXIF ๋ฐ์ดํฐ ์ ์งํ๋ฉฐ ํธ์ง
๐ ๋ ์์๋ณด๊ธฐ
โก๏ธ ์ฑ๋ฅ ํ: ๋ ์ด์ด๋ฅผ ๋ณํฉ(flatten)ํ์ฌ ์ต์ข
์ถ๋ ฅ ์ ์ฑ๋ฅ์ ํฅ์์ํค์ธ์. ํธ์ง ์ค์๋ ๋ ์ด์ด๋ฅผ ์ ์งํ๊ณ , ๊ณต์ /์ ์ฅ ์์๋ง ๋ณํฉํ์ธ์.