๐Ÿ‡บ๐Ÿ‡ธ EN

๐Ÿ–ผ๏ธ 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 ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค๋ฌด ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ์„ฑ๋Šฅ ํŒ: ๋ ˆ์ด์–ด๋ฅผ ๋ณ‘ํ•ฉ(flatten)ํ•˜์—ฌ ์ตœ์ข… ์ถœ๋ ฅ ์‹œ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ค์„ธ์š”. ํŽธ์ง‘ ์ค‘์—๋Š” ๋ ˆ์ด์–ด๋ฅผ ์œ ์ง€ํ•˜๊ณ , ๊ณต์œ /์ €์žฅ ์‹œ์—๋งŒ ๋ณ‘ํ•ฉํ•˜์„ธ์š”.

๐Ÿ“Ž Apple ๊ณต์‹ ์ž๋ฃŒ

๐Ÿ“˜ ๊ณต์‹ ๋ฌธ์„œ ๐ŸŽฌ WWDC ์„ธ์…˜