SwiftUI

이미지/텍스트 편집기: 이미지/텍스트 삭제(6)

development30years 2024. 12. 11. 13:01

구현 목표

  • 선택한 이미지나 텍스트를 삭제한다.

설명 및 소스 코드

  • 선택된 이미지/텍스트가 있으면(activeId != nil) 이미지/텍스트를 배열에서 삭제한다.
  • id를 UUID로 설정했기 때문에, imageInfos, textInfos, items 모두 id만을 비교해서 삭제하면 된다.
  • 아이콘 메뉴는 아래에 붙인다. ignoreSafeArea를 적용하되, 아래쪽의 ignoreSafeArea 영역만큼은UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0 을 사용하여 padding 처리한다.

ContentView.swift

import SwiftUI

struct ZoomState: Identifiable {
    let id = UUID()
    var scale: CGFloat = 1.0
    var offset: CGSize = .zero
    var gestureScale: CGFloat = 1.0
    var gestureOffset: CGSize = .zero
    var anchor: UnitPoint = .center
}

struct TextInfo {
    var state = ZoomState()
// will do:    var fontName: String = "SF Pro"
// will do:    var weight: Font.Weight = .regular
}

struct ImageInfo {
    var state = ZoomState()
// will do:    var bright: CGFloat = .zero
// will do:    var contrast: CGFloat = .zero
}

enum ItemAttr {
    case image, text
}

struct Item {
    var attr: ItemAttr
    var id: UUID
}

struct ContentView: View {
    @State private var imageInfos: [ImageInfo] = []
    @State private var textInfos: [TextInfo] = []
    @State private var items: [Item] = []
    @State private var activeId: UUID?
    @State private var canNewActive = true

    func getItemAttrIndex(itemIndex: Int, itemAttr: ItemAttr) -> Int {
        var index = -1
        for i in 0...itemIndex {
            if items[i].attr == itemAttr {
                index += 1
            }
        }
        
        return index
    }
    
    var body: some View {
        VStack {
            ZStack {
                ForEach(items.indices, id: \.self) { index in
                    if items[index].attr == .image {
                        let imageIndex = getItemAttrIndex(itemIndex: index, itemAttr: .image)
                        if imageIndex != -1 {
                            ZoomImage(state: $imageInfos[imageIndex].state, activeId: $activeId, canNewActive: $canNewActive)
                        }
                    } else {
                        let textIndex = getItemAttrIndex(itemIndex: index, itemAttr: .text)
                        if textIndex != -1 {
                            ZoomText(state: $textInfos[textIndex].state, activeId: $activeId, canNewActive: $canNewActive)
                        }
                    }
                }
            }
            .zIndex(-1)
            HStack {
                Image(systemName: "trash")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 24, height: 24)
                    .padding(8)
                    .background(.gray.opacity(0.2))
                    .clipShape(Circle())
                    .onTapGesture {
                        if activeId != nil {
                            imageInfos.removeAll(where: {activeId == $0.state.id})
                            textInfos.removeAll(where: {activeId == $0.state.id})
                            items.removeAll(where: {activeId == $0.id})
                            activeId = nil
                        }
                    }
                Spacer()

                HStack {
                    Image(systemName: "photo")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 24, height: 24)
                        .padding(8)
                        .onTapGesture {
                            let imageInfo = ImageInfo()
                            imageInfos.append(imageInfo)
                            items.append(Item(attr: .image, id: imageInfo.state.id))
                            activeId = imageInfo.state.id
                        }
                    Image(systemName: "textformat.size.larger")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 24, height: 24)
                        .padding(8)
                        .onTapGesture {
                            let textInfo = TextInfo()
                            textInfos.append(textInfo)
                            items.append(Item(attr: .text, id: textInfo.state.id))
                            activeId = textInfo.state.id
                        }
                }
                .padding(.horizontal)
                .background(.gray.opacity(0.2))
                .clipShape(RoundedRectangle(cornerRadius: 24))
            }
            .padding()
            .frame(maxWidth: .infinity)
            .frame(height: 40)
            .background(.white)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
        .padding(.bottom, UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0)
        .ignoresSafeArea()
    }
}

#Preview {
    ContentView()
}

 

ZoomImage.swift

import SwiftUI

struct ZoomImage: View {
    @Binding var state: ZoomState
    @Binding var activeId: UUID?
    @Binding var canNewActive: Bool
    let size: CGSize = CGSize(width: 200, height: 200)
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                let maxOffsetX = geometry.size.width / 2 / state.scale
                let maxOffsetY = geometry.size.height / 2 / state.scale
                Image(systemName: "globe")
                    .resizable()
                    .scaledToFit()
                    .frame(width: size.width, height: size.height)
                    .border(.blue, width:  activeId == state.id ?  2 : 0)
                    .offset(state.offset)
                    .scaleEffect(state.scale * state.gestureScale, anchor: state.anchor)
                    .gesture(
                        SimultaneousGesture(
                            DragGesture()
                                .onChanged { value in
                                    if activeId == nil || canNewActive || activeId == state.id {
                                        activeId = state.id
                                        canNewActive = false
                                        state.offset.width =  min(max(state.offset.width + (value.translation.width - state.gestureOffset.width) / state.scale, -maxOffsetX), maxOffsetX)
                                        state.offset.height = min(max(state.offset.height + (value.translation.height - state.gestureOffset.height) / state.scale, -maxOffsetY), maxOffsetY)
                                        state.gestureOffset = value.translation
                                    }
                                }
                                .onEnded { value in
                                    state.gestureOffset = .zero
                                    canNewActive = true
                                },
                            MagnifyGesture()
                                .onChanged({ value in
                                    if activeId == nil || canNewActive || activeId == state.id {
                                        canNewActive = false
                                        activeId = state.id
                                        state.anchor = value.startAnchor
                                        state.gestureScale = value.magnification
                                    }
                                })
                                .onEnded({ value in
                                    state.scale *= value.magnification
                                    state.gestureScale = 1.0
                                    canNewActive = true
                                })
                        )
                        
                    )
                    .onTapGesture {
                        if activeId == nil || canNewActive {
                            activeId = state.id
                        }
                    }
                    .animation(.interactiveSpring(), value: state.gestureOffset)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        }
    }
}

#Preview {
    ZoomImage(state: .constant(ZoomState()), activeId: .constant(nil), canNewActive: .constant(true))
}

 

ZoomText.swift

import SwiftUI

struct ZoomText: View {
    @Binding var state: ZoomState
    @Binding var activeId: UUID?
    @Binding var canNewActive: Bool
    var body: some View {
        GeometryReader { geometry in
            let maxOffsetX = geometry.size.width / 2
            let maxOffsetY = geometry.size.height / 2
            Text("Hello, development30years")
                .font(.title)
                .fixedSize()
                .border(.blue, width:  activeId == state.id ?  2 : 0)
                .scaleEffect(state.scale * state.gestureScale)
                .offset(state.offset)
                .gesture(
                    SimultaneousGesture(
                        DragGesture()
                            .onChanged { value in
                                if activeId == nil || canNewActive || activeId == state.id {
                                    activeId = state.id
                                    canNewActive = false
                                    state.offset.width =  min(max(state.offset.width + (value.translation.width - state.gestureOffset.width), -maxOffsetX), maxOffsetX)
                                    state.offset.height = min(max(state.offset.height + (value.translation.height - state.gestureOffset.height), -maxOffsetY), maxOffsetY)
                                    state.gestureOffset = value.translation
                                }
                            }
                            .onEnded { value in
                                state.gestureOffset = .zero
                                canNewActive = true
                            },
                        MagnifyGesture()
                            .onChanged({ value in
                                if activeId == nil || canNewActive || activeId == state.id {
                                    activeId = state.id
                                    canNewActive = false
                                    state.gestureScale = value.magnification
                                }
                            })
                            .onEnded({ value in
                                state.scale *= value.magnification
                                state.gestureScale = 1.0
                                canNewActive = true
                           })
                    )
                )
                .onTapGesture {
                    if activeId == nil || canNewActive {
                        activeId = state.id
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}

#Preview {
    ZoomText(state: .constant(ZoomState()), activeId: .constant(nil), canNewActive: .constant(true))
}