feat(view, viewmodel, model): 增加 document 和 palette,增加手势
1. 可以从另一个 app 中拖入图片或图片链接作为背景 2. 可以从 palette 中拖入 emoji 3. 可以拖动和双击缩放背景图 Signed-off-by: Ching <loooching@gmail.com>
This commit is contained in:
parent
b6d09f314e
commit
89d38faea4
@ -8,18 +8,30 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
24145E7C29A1489700ECB9D1 /* EmojiArtApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24145E7B29A1489700ECB9D1 /* EmojiArtApp.swift */; };
|
24145E7C29A1489700ECB9D1 /* EmojiArtApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24145E7B29A1489700ECB9D1 /* EmojiArtApp.swift */; };
|
||||||
24145E7E29A1489700ECB9D1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24145E7D29A1489700ECB9D1 /* ContentView.swift */; };
|
24145E7E29A1489700ECB9D1 /* EmojiArtDocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24145E7D29A1489700ECB9D1 /* EmojiArtDocumentView.swift */; };
|
||||||
24145E8029A1489800ECB9D1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 24145E7F29A1489800ECB9D1 /* Assets.xcassets */; };
|
24145E8029A1489800ECB9D1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 24145E7F29A1489800ECB9D1 /* Assets.xcassets */; };
|
||||||
24145E8429A1489800ECB9D1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 24145E8329A1489800ECB9D1 /* Preview Assets.xcassets */; };
|
24145E8429A1489800ECB9D1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 24145E8329A1489800ECB9D1 /* Preview Assets.xcassets */; };
|
||||||
|
24145E8B29A1498500ECB9D1 /* EmojiArtModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24145E8A29A1498500ECB9D1 /* EmojiArtModel.swift */; };
|
||||||
|
24145E8D29A225E900ECB9D1 /* EmojiArtModel.Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24145E8C29A225E900ECB9D1 /* EmojiArtModel.Background.swift */; };
|
||||||
|
248D14EC29A227C700AE4C0D /* EmojiArtDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248D14EB29A227C700AE4C0D /* EmojiArtDocument.swift */; };
|
||||||
|
248D14F029A230FE00AE4C0D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248D14EF29A230FE00AE4C0D /* Constants.swift */; };
|
||||||
|
248D14F329A2435000AE4C0D /* UtilityViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248D14F129A2435000AE4C0D /* UtilityViews.swift */; };
|
||||||
|
248D14F629A243F200AE4C0D /* UtilityExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248D14F529A243F200AE4C0D /* UtilityExtensions.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
24145E7829A1489700ECB9D1 /* EmojiArt.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EmojiArt.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
24145E7829A1489700ECB9D1 /* EmojiArt.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EmojiArt.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
24145E7B29A1489700ECB9D1 /* EmojiArtApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtApp.swift; sourceTree = "<group>"; };
|
24145E7B29A1489700ECB9D1 /* EmojiArtApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtApp.swift; sourceTree = "<group>"; };
|
||||||
24145E7D29A1489700ECB9D1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
24145E7D29A1489700ECB9D1 /* EmojiArtDocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtDocumentView.swift; sourceTree = "<group>"; };
|
||||||
24145E7F29A1489800ECB9D1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
24145E7F29A1489800ECB9D1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
24145E8129A1489800ECB9D1 /* EmojiArt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EmojiArt.entitlements; sourceTree = "<group>"; };
|
24145E8129A1489800ECB9D1 /* EmojiArt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EmojiArt.entitlements; sourceTree = "<group>"; };
|
||||||
24145E8329A1489800ECB9D1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
24145E8329A1489800ECB9D1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||||
|
24145E8A29A1498500ECB9D1 /* EmojiArtModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtModel.swift; sourceTree = "<group>"; };
|
||||||
|
24145E8C29A225E900ECB9D1 /* EmojiArtModel.Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtModel.Background.swift; sourceTree = "<group>"; };
|
||||||
|
248D14EB29A227C700AE4C0D /* EmojiArtDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtDocument.swift; sourceTree = "<group>"; };
|
||||||
|
248D14EF29A230FE00AE4C0D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
|
||||||
|
248D14F129A2435000AE4C0D /* UtilityViews.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UtilityViews.swift; sourceTree = "<group>"; };
|
||||||
|
248D14F529A243F200AE4C0D /* UtilityExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UtilityExtensions.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -52,8 +64,14 @@
|
|||||||
24145E7A29A1489700ECB9D1 /* EmojiArt */ = {
|
24145E7A29A1489700ECB9D1 /* EmojiArt */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
248D14EF29A230FE00AE4C0D /* Constants.swift */,
|
||||||
24145E7B29A1489700ECB9D1 /* EmojiArtApp.swift */,
|
24145E7B29A1489700ECB9D1 /* EmojiArtApp.swift */,
|
||||||
24145E7D29A1489700ECB9D1 /* ContentView.swift */,
|
24145E7D29A1489700ECB9D1 /* EmojiArtDocumentView.swift */,
|
||||||
|
248D14F129A2435000AE4C0D /* UtilityViews.swift */,
|
||||||
|
24145E8A29A1498500ECB9D1 /* EmojiArtModel.swift */,
|
||||||
|
248D14F529A243F200AE4C0D /* UtilityExtensions.swift */,
|
||||||
|
248D14EB29A227C700AE4C0D /* EmojiArtDocument.swift */,
|
||||||
|
24145E8C29A225E900ECB9D1 /* EmojiArtModel.Background.swift */,
|
||||||
24145E7F29A1489800ECB9D1 /* Assets.xcassets */,
|
24145E7F29A1489800ECB9D1 /* Assets.xcassets */,
|
||||||
24145E8129A1489800ECB9D1 /* EmojiArt.entitlements */,
|
24145E8129A1489800ECB9D1 /* EmojiArt.entitlements */,
|
||||||
24145E8229A1489800ECB9D1 /* Preview Content */,
|
24145E8229A1489800ECB9D1 /* Preview Content */,
|
||||||
@ -139,8 +157,14 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
24145E7E29A1489700ECB9D1 /* ContentView.swift in Sources */,
|
24145E7E29A1489700ECB9D1 /* EmojiArtDocumentView.swift in Sources */,
|
||||||
24145E7C29A1489700ECB9D1 /* EmojiArtApp.swift in Sources */,
|
24145E7C29A1489700ECB9D1 /* EmojiArtApp.swift in Sources */,
|
||||||
|
248D14EC29A227C700AE4C0D /* EmojiArtDocument.swift in Sources */,
|
||||||
|
248D14F029A230FE00AE4C0D /* Constants.swift in Sources */,
|
||||||
|
24145E8B29A1498500ECB9D1 /* EmojiArtModel.swift in Sources */,
|
||||||
|
24145E8D29A225E900ECB9D1 /* EmojiArtModel.Background.swift in Sources */,
|
||||||
|
248D14F629A243F200AE4C0D /* UtilityExtensions.swift in Sources */,
|
||||||
|
248D14F329A2435000AE4C0D /* UtilityViews.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
14
EmojiArt/Constants.swift
Normal file
14
EmojiArt/Constants.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// Constants.swift
|
||||||
|
// EmojiArt
|
||||||
|
//
|
||||||
|
// Created by ching on 2023/2/19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct const {
|
||||||
|
static let defaultEmojiFontSize: CGFloat = 40
|
||||||
|
}
|
||||||
@ -1,26 +0,0 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// EmojiArt
|
|
||||||
//
|
|
||||||
// Created by ching on 2023/2/19.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text("Hello, world!")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,9 +9,10 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct EmojiArtApp: App {
|
struct EmojiArtApp: App {
|
||||||
|
let document = EmojiArtDocument()
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
EmojiArtDocumentView(document: document)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
EmojiArt/EmojiArtDocument.swift
Normal file
80
EmojiArt/EmojiArtDocument.swift
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// EmojiArtDocument.swift
|
||||||
|
// EmojiArt
|
||||||
|
//
|
||||||
|
// Created by ching on 2023/2/19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class EmojiArtDocument: ObservableObject {
|
||||||
|
@Published private(set) var emojiArt: EmojiArtModel {
|
||||||
|
didSet {
|
||||||
|
if emojiArt.background != oldValue.background {
|
||||||
|
fetchBackgoundImageDataIfNecessary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
emojiArt = EmojiArtModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
var emojis: [EmojiArtModel.Emoji] { emojiArt.emojis }
|
||||||
|
var background: EmojiArtModel.Background { emojiArt.background }
|
||||||
|
@Published var backgroundImage: UIImage?
|
||||||
|
@Published var backgroundImageFetchStatus = BackgroundImageFetchStatus.idle
|
||||||
|
|
||||||
|
enum BackgroundImageFetchStatus {
|
||||||
|
case idle
|
||||||
|
case fetching
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Intent(s)
|
||||||
|
|
||||||
|
func setBackground(_ background: EmojiArtModel.Background) {
|
||||||
|
emojiArt.background = background
|
||||||
|
print("background set to \(background)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEmoji(_ emoji: String, at location: (x: Int, y: Int), size: CGFloat) {
|
||||||
|
emojiArt.addEmoji(emoji, at: location, size: Int(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveEmoji(_ emoji: EmojiArtModel.Emoji, by offset: CGSize) {
|
||||||
|
if let index = emojiArt.emojis.index(matching: emoji) {
|
||||||
|
emojiArt.emojis[index].x += Int(offset.width)
|
||||||
|
emojiArt.emojis[index].y += Int(offset.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaleEmoji(_ emoji: EmojiArtModel.Emoji, by scale: CGFloat) {
|
||||||
|
if let index = emojiArt.emojis.index(matching: emoji) {
|
||||||
|
emojiArt.emojis[index].size = Int((CGFloat(emojiArt.emojis[index].size) * scale).rounded(.toNearestOrAwayFromZero))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchBackgoundImageDataIfNecessary() {
|
||||||
|
backgroundImage = nil
|
||||||
|
switch emojiArt.background {
|
||||||
|
case .url(let url):
|
||||||
|
// fetch url
|
||||||
|
backgroundImageFetchStatus = .fetching
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let imageData = try? Data(contentsOf: url)
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
if self?.emojiArt.background == EmojiArtModel.Background.url(url) {
|
||||||
|
self?.backgroundImageFetchStatus = .idle
|
||||||
|
if imageData != nil {
|
||||||
|
self?.backgroundImage = UIImage(data: imageData!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .imageData(let data):
|
||||||
|
backgroundImage = UIImage(data: data)
|
||||||
|
case .blank:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
EmojiArt/EmojiArtDocumentView.swift
Normal file
171
EmojiArt/EmojiArtDocumentView.swift
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
//
|
||||||
|
// EmojiArtDocumentView.swift
|
||||||
|
// EmojiArt
|
||||||
|
//
|
||||||
|
// Created by ching on 2023/2/19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EmojiArtDocumentView: View {
|
||||||
|
let testEmojis = "😀😃😄😁😆😅😂🤣🥲🥹☺️😊😇🙂🙃😉😌😍🥰😘😗😙😚😋😛😝😜🤪🤨🧐🤓😎🥸🤩"
|
||||||
|
|
||||||
|
@ObservedObject var document: EmojiArtDocument
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
documentBody
|
||||||
|
palette
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var documentBody: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
Color.yellow.overlay(
|
||||||
|
OptionalImage(uiImage: document.backgroundImage)
|
||||||
|
.scaleEffect(zoomScale)
|
||||||
|
.position(convertFromEmojiCoordinates((0, 0), in: geometry))
|
||||||
|
)
|
||||||
|
.gesture(doubleTapToZoom(in: geometry.size))
|
||||||
|
if document.backgroundImageFetchStatus == .fetching {
|
||||||
|
ProgressView().scaleEffect(2)
|
||||||
|
} else {
|
||||||
|
ForEach(document.emojis) { emoji in
|
||||||
|
Text(emoji.text)
|
||||||
|
.font(.system(size: fontSize(for: emoji)))
|
||||||
|
.scaleEffect(zoomScale)
|
||||||
|
.position(position(for: emoji, in: geometry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
.onDrop(of: [.plainText, .url, .image], isTargeted: nil) { providers, location in
|
||||||
|
drop(providers: providers, at: location, in: geometry)
|
||||||
|
}
|
||||||
|
.gesture(panGesture().simultaneously(with: zoomGesture()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drop(providers: [NSItemProvider], at location: CGPoint, in geometry: GeometryProxy) -> Bool {
|
||||||
|
var found = providers.loadObjects(ofType: URL.self) { url in
|
||||||
|
document.setBackground(EmojiArtModel.Background.url(url.imageURL))
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
found = providers.loadObjects(ofType: UIImage.self) { image in
|
||||||
|
if let data = image.jpegData(compressionQuality: 1.0) {
|
||||||
|
document.setBackground(.imageData(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
found = providers.loadObjects(ofType: String.self) { string in
|
||||||
|
if let emoji = string.first, emoji.isEmoji {
|
||||||
|
document.addEmoji(
|
||||||
|
String(emoji),
|
||||||
|
at: convertToEmojiCoordinates(location, in: geometry),
|
||||||
|
size: const.defaultEmojiFontSize / zoomScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fontSize(for emoji: EmojiArtModel.Emoji) -> CGFloat {
|
||||||
|
CGFloat(emoji.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func position(for emoji: EmojiArtModel.Emoji, in geometry: GeometryProxy) -> CGPoint {
|
||||||
|
convertFromEmojiCoordinates((emoji.x, emoji.y), in: geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertToEmojiCoordinates(_ location: CGPoint, in geometry: GeometryProxy) -> (x: Int, y: Int) {
|
||||||
|
let center = geometry.frame(in: .local).center
|
||||||
|
let location = CGPoint(
|
||||||
|
x: (location.x - center.x - panOffset.width) / zoomScale,
|
||||||
|
y: (location.y - center.y - panOffset.height) / zoomScale)
|
||||||
|
return (Int(location.x), Int(location.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertFromEmojiCoordinates(_ location: (x: Int, y: Int), in geometry: GeometryProxy) -> CGPoint {
|
||||||
|
let center = geometry.frame(in: .local).center
|
||||||
|
return CGPoint(
|
||||||
|
x: center.x + CGFloat(location.x) * zoomScale + panOffset.width,
|
||||||
|
y: center.y + CGFloat(location.y) * zoomScale + panOffset.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var steadyStatePanOffset: CGSize = .zero
|
||||||
|
@GestureState private var gesturePanOffset: CGSize = .zero
|
||||||
|
private var panOffset: CGSize {
|
||||||
|
(steadyStatePanOffset + gesturePanOffset) * zoomScale
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var steadyStateZoomScale: CGFloat = 1
|
||||||
|
@GestureState private var gestureZoomScale: CGFloat = 1
|
||||||
|
private var zoomScale: CGFloat {
|
||||||
|
steadyStateZoomScale * gestureZoomScale
|
||||||
|
}
|
||||||
|
|
||||||
|
private func panGesture() -> some Gesture {
|
||||||
|
DragGesture()
|
||||||
|
.updating($gesturePanOffset) { latestDragGestureValue, gesturePanOffset, _ in
|
||||||
|
gesturePanOffset = latestDragGestureValue.translation / zoomScale
|
||||||
|
}
|
||||||
|
.onEnded { finalDragGestureValue in
|
||||||
|
steadyStatePanOffset = steadyStatePanOffset + (finalDragGestureValue.translation / zoomScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zoomGesture() -> some Gesture {
|
||||||
|
MagnificationGesture()
|
||||||
|
.updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in
|
||||||
|
gestureZoomScale = latestGestureScale
|
||||||
|
}
|
||||||
|
.onEnded { gestureScaleAtEnd in
|
||||||
|
steadyStateZoomScale *= gestureScaleAtEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doubleTapToZoom(in size: CGSize) -> some Gesture {
|
||||||
|
TapGesture(count: 2)
|
||||||
|
.onEnded {
|
||||||
|
withAnimation {
|
||||||
|
zoomToFit(document.backgroundImage, in: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zoomToFit(_ image: UIImage?, in size: CGSize) {
|
||||||
|
if let image = image, image.size.width > 0, image.size.height > 0, size.width > 0, size.height > 0 {
|
||||||
|
let hZoom = size.width / image.size.width
|
||||||
|
let vZoom = size.height / image.size.height
|
||||||
|
steadyStatePanOffset = .zero
|
||||||
|
steadyStateZoomScale = min(hZoom, vZoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var palette: some View {
|
||||||
|
SrcollingEmojiView(emojis: testEmojis)
|
||||||
|
.font(.system(size: const.defaultEmojiFontSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SrcollingEmojiView: View {
|
||||||
|
let emojis: String
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack {
|
||||||
|
ForEach(emojis.map { String($0) }, id: \.self) { emoji in
|
||||||
|
Text(emoji)
|
||||||
|
.onDrag { NSItemProvider(object: emoji as NSString) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
EmojiArtDocumentView(document: EmojiArtDocument())
|
||||||
|
}
|
||||||
|
}
|
||||||
30
EmojiArt/EmojiArtModel.Background.swift
Normal file
30
EmojiArt/EmojiArtModel.Background.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// EmojiArtModel.Background.swift
|
||||||
|
// EmojiArt
|
||||||
|
//
|
||||||
|
// Created by ching on 2023/2/19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension EmojiArtModel {
|
||||||
|
enum Background: Equatable {
|
||||||
|
case blank
|
||||||
|
case url(URL)
|
||||||
|
case imageData(Data)
|
||||||
|
|
||||||
|
var url: URL? {
|
||||||
|
switch self {
|
||||||
|
case .url(let url): return url
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageData: Data? {
|
||||||
|
switch self {
|
||||||
|
case .imageData(let data): return data
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
EmojiArt/EmojiArtModel.swift
Normal file
40
EmojiArt/EmojiArtModel.swift
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// EmojiArtModel.swift
|
||||||
|
// EmojiArt
|
||||||
|
//
|
||||||
|
// Created by ching on 2023/2/19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct EmojiArtModel {
|
||||||
|
var background: Background = EmojiArtModel.Background.blank
|
||||||
|
var emojis = [Emoji]()
|
||||||
|
|
||||||
|
struct Emoji: Identifiable {
|
||||||
|
let text: String
|
||||||
|
var x: Int
|
||||||
|
var y: Int
|
||||||
|
var size: Int
|
||||||
|
var id: Int
|
||||||
|
|
||||||
|
fileprivate init(text: String, x: Int, y: Int, size: Int, id: Int) {
|
||||||
|
self.text = text
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.size = size
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private var uniqueEmojiId = 0
|
||||||
|
mutating func addEmoji(_ text: String, at location: (x: Int, y: Int), size: Int) {
|
||||||
|
uniqueEmojiId += 1
|
||||||
|
emojis.append(Emoji(text: text, x: location.x, y: location.y, size: size, id: uniqueEmojiId))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
254
EmojiArt/UtilityExtensions.swift
Normal file
254
EmojiArt/UtilityExtensions.swift
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
//
|
||||||
|
// UtilityExtensions.swift
|
||||||
|
// EmojiArt
|
||||||
|
//
|
||||||
|
// Created by CS193p Instructor on 4/26/21.
|
||||||
|
// Copyright © 2021 Stanford University. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// in a Collection of Identifiables
|
||||||
|
// we often might want to find the element that has the same id
|
||||||
|
// as an Identifiable we already have in hand
|
||||||
|
// we name this index(matching:) instead of firstIndex(matching:)
|
||||||
|
// because we assume that someone creating a Collection of Identifiable
|
||||||
|
// is usually going to have only one of each Identifiable thing in there
|
||||||
|
// (though there's nothing to restrict them from doing so; it's just a naming choice)
|
||||||
|
|
||||||
|
extension Collection where Element: Identifiable {
|
||||||
|
func index(matching element: Element) -> Self.Index? {
|
||||||
|
firstIndex(where: { $0.id == element.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we could do the same thing when it comes to removing an element
|
||||||
|
// but we have to add that to a different protocol
|
||||||
|
// because Collection works for immutable collections of things
|
||||||
|
// the "mutable" one is RangeReplaceableCollection
|
||||||
|
// not only could we add remove
|
||||||
|
// but we could add a subscript which takes a copy of one of the elements
|
||||||
|
// and uses its Identifiable-ness to subscript into the Collection
|
||||||
|
// this is an awesome way to create Bindings into an Array in a ViewModel
|
||||||
|
// (since any Published var in an ObservableObject can be bound to via $)
|
||||||
|
// (even vars on that Published var or subscripts on that var)
|
||||||
|
// (or subscripts on vars on that var, etc.)
|
||||||
|
|
||||||
|
extension RangeReplaceableCollection where Element: Identifiable {
|
||||||
|
mutating func remove(_ element: Element) {
|
||||||
|
if let index = index(matching: element) {
|
||||||
|
remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript(_ element: Element) -> Element {
|
||||||
|
get {
|
||||||
|
if let index = index(matching: element) {
|
||||||
|
return self[index]
|
||||||
|
} else {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if let index = index(matching: element) {
|
||||||
|
replaceSubrange(index...index, with: [newValue])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if you use a Set to represent the selection of emoji in HW5
|
||||||
|
// then you might find this syntactic sugar function to be of use
|
||||||
|
|
||||||
|
extension Set where Element: Identifiable {
|
||||||
|
mutating func toggleMembership(of element: Element) {
|
||||||
|
if let index = index(matching: element) {
|
||||||
|
remove(at: index)
|
||||||
|
} else {
|
||||||
|
insert(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// some extensions to String and Character
|
||||||
|
// to help us with managing our Strings of emojis
|
||||||
|
// we want them to be "emoji only"
|
||||||
|
// (thus isEmoji below)
|
||||||
|
// and we don't want them to have repeated emojis
|
||||||
|
// (thus withNoRepeatedCharacters below)
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var withNoRepeatedCharacters: String {
|
||||||
|
var uniqued = ""
|
||||||
|
for ch in self {
|
||||||
|
if !uniqued.contains(ch) {
|
||||||
|
uniqued.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniqued
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Character {
|
||||||
|
var isEmoji: Bool {
|
||||||
|
// Swift does not have a way to ask if a Character isEmoji
|
||||||
|
// but it does let us check to see if our component scalars isEmoji
|
||||||
|
// unfortunately unicode allows certain scalars (like 1)
|
||||||
|
// to be modified by another scalar to become emoji (e.g. 1️⃣)
|
||||||
|
// so the scalar "1" will report isEmoji = true
|
||||||
|
// so we can't just check to see if the first scalar isEmoji
|
||||||
|
// the quick and dirty here is to see if the scalar is at least the first true emoji we know of
|
||||||
|
// (the start of the "miscellaneous items" section)
|
||||||
|
// or check to see if this is a multiple scalar unicode sequence
|
||||||
|
// (e.g. a 1 with a unicode modifier to force it to be presented as emoji 1️⃣)
|
||||||
|
if let firstScalar = unicodeScalars.first, firstScalar.properties.isEmoji {
|
||||||
|
return (firstScalar.value >= 0x238d || unicodeScalars.count > 1)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extracting the actual url to an image from a url that might contain other info
|
||||||
|
// (essentially looking for the imgurl key)
|
||||||
|
// imgurl is a "well known" key that can be embedded in a url that says what the actual image url is
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
var imageURL: URL {
|
||||||
|
for query in query?.components(separatedBy: "&") ?? [] {
|
||||||
|
let queryComponents = query.components(separatedBy: "=")
|
||||||
|
if queryComponents.count == 2 {
|
||||||
|
if queryComponents[0] == "imgurl", let url = URL(string: queryComponents[1].removingPercentEncoding ?? "") {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseURL ?? self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convenience functions for adding/subtracting CGPoints and CGSizes
|
||||||
|
// might come in handy when doing gesture handling
|
||||||
|
// because we do a lot of converting between coordinate systems and such
|
||||||
|
// notice that type types of the lhs and rhs arguments vary below
|
||||||
|
// thus you can offset a CGPoint by the width and height of a CGSize, for example
|
||||||
|
|
||||||
|
extension DragGesture.Value {
|
||||||
|
var distance: CGSize { location - startLocation }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGRect {
|
||||||
|
var center: CGPoint {
|
||||||
|
CGPoint(x: midX, y: midY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGPoint {
|
||||||
|
static func -(lhs: Self, rhs: Self) -> CGSize {
|
||||||
|
CGSize(width: lhs.x - rhs.x, height: lhs.y - rhs.y)
|
||||||
|
}
|
||||||
|
static func +(lhs: Self, rhs: CGSize) -> CGPoint {
|
||||||
|
CGPoint(x: lhs.x + rhs.width, y: lhs.y + rhs.height)
|
||||||
|
}
|
||||||
|
static func -(lhs: Self, rhs: CGSize) -> CGPoint {
|
||||||
|
CGPoint(x: lhs.x - rhs.width, y: lhs.y - rhs.height)
|
||||||
|
}
|
||||||
|
static func *(lhs: Self, rhs: CGFloat) -> CGPoint {
|
||||||
|
CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
|
||||||
|
}
|
||||||
|
static func /(lhs: Self, rhs: CGFloat) -> CGPoint {
|
||||||
|
CGPoint(x: lhs.x / rhs, y: lhs.y / rhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGSize {
|
||||||
|
// the center point of an area that is our size
|
||||||
|
var center: CGPoint {
|
||||||
|
CGPoint(x: width/2, y: height/2)
|
||||||
|
}
|
||||||
|
static func +(lhs: Self, rhs: Self) -> CGSize {
|
||||||
|
CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
|
||||||
|
}
|
||||||
|
static func -(lhs: Self, rhs: Self) -> CGSize {
|
||||||
|
CGSize(width: lhs.width - rhs.width, height: lhs.height - rhs.height)
|
||||||
|
}
|
||||||
|
static func *(lhs: Self, rhs: CGFloat) -> CGSize {
|
||||||
|
CGSize(width: lhs.width * rhs, height: lhs.height * rhs)
|
||||||
|
}
|
||||||
|
static func /(lhs: Self, rhs: CGFloat) -> CGSize {
|
||||||
|
CGSize(width: lhs.width/rhs, height: lhs.height/rhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add RawRepresentable protocol conformance to CGSize and CGFloat
|
||||||
|
// so that they can be used with @SceneStorage
|
||||||
|
// we do this by first providing default implementations of rawValue and init(rawValue:)
|
||||||
|
// in RawRepresentable when the thing in question is Codable (which both CGFloat and CGSize are)
|
||||||
|
// then all it takes to make something that is Codable be RawRepresentable is to declare it to be so
|
||||||
|
// (it will then get the default implementions needed to be a RawRepresentable)
|
||||||
|
|
||||||
|
extension RawRepresentable where Self: Codable {
|
||||||
|
public var rawValue: String {
|
||||||
|
if let json = try? JSONEncoder().encode(self), let string = String(data: json, encoding: .utf8) {
|
||||||
|
return string
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public init?(rawValue: String) {
|
||||||
|
if let value = try? JSONDecoder().decode(Self.self, from: Data(rawValue.utf8)) {
|
||||||
|
self = value
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGSize: RawRepresentable { }
|
||||||
|
extension CGFloat: RawRepresentable { }
|
||||||
|
|
||||||
|
// convenience functions for [NSItemProvider] (i.e. array of NSItemProvider)
|
||||||
|
// makes the code for loading objects from the providers a bit simpler
|
||||||
|
// NSItemProvider is a holdover from the Objective-C (i.e. pre-Swift) world
|
||||||
|
// you can tell by its very name (starts with NS)
|
||||||
|
// so unfortunately, dealing with this API is a little bit crufty
|
||||||
|
// thus I recommend you just accept that these loadObjects functions will work and move on
|
||||||
|
// it's a rare case where trying to dive in and understand what's going on here
|
||||||
|
// would probably not be a very efficient use of your time
|
||||||
|
// (though I'm certainly not going to say you shouldn't!)
|
||||||
|
// (just trying to help you optimize your valuable time this quarter)
|
||||||
|
|
||||||
|
extension Array where Element == NSItemProvider {
|
||||||
|
func loadObjects<T>(ofType theType: T.Type, firstOnly: Bool = false, using load: @escaping (T) -> Void) -> Bool where T: NSItemProviderReading {
|
||||||
|
if let provider = first(where: { $0.canLoadObject(ofClass: theType) }) {
|
||||||
|
provider.loadObject(ofClass: theType) { object, error in
|
||||||
|
if let value = object as? T {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
load(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func loadObjects<T>(ofType theType: T.Type, firstOnly: Bool = false, using load: @escaping (T) -> Void) -> Bool where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading {
|
||||||
|
if let provider = first(where: { $0.canLoadObject(ofClass: theType) }) {
|
||||||
|
let _ = provider.loadObject(ofClass: theType) { object, error in
|
||||||
|
if let value = object {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
load(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func loadFirstObject<T>(ofType theType: T.Type, using load: @escaping (T) -> Void) -> Bool where T: NSItemProviderReading {
|
||||||
|
loadObjects(ofType: theType, firstOnly: true, using: load)
|
||||||
|
}
|
||||||
|
func loadFirstObject<T>(ofType theType: T.Type, using load: @escaping (T) -> Void) -> Bool where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading {
|
||||||
|
loadObjects(ofType: theType, firstOnly: true, using: load)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
EmojiArt/UtilityViews.swift
Normal file
65
EmojiArt/UtilityViews.swift
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// UtilityViews.swift
|
||||||
|
// EmojiArt
|
||||||
|
//
|
||||||
|
// Created by CS193p Instructor on 4/26/21.
|
||||||
|
// Copyright © 2021 Stanford University. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// syntactic sure to be able to pass an optional UIImage to Image
|
||||||
|
// (normally it would only take a non-optional UIImage)
|
||||||
|
|
||||||
|
struct OptionalImage: View {
|
||||||
|
var uiImage: UIImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if uiImage != nil {
|
||||||
|
Image(uiImage: uiImage!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syntactic sugar
|
||||||
|
// lots of times we want a simple button
|
||||||
|
// with just text or a label or a systemImage
|
||||||
|
// but we want the action it performs to be animated
|
||||||
|
// (i.e. withAnimation)
|
||||||
|
// this just makes it easy to create such a button
|
||||||
|
// and thus cleans up our code
|
||||||
|
|
||||||
|
struct AnimatedActionButton: View {
|
||||||
|
var title: String? = nil
|
||||||
|
var systemImage: String? = nil
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if title != nil && systemImage != nil {
|
||||||
|
Label(title!, systemImage: systemImage!)
|
||||||
|
} else if title != nil {
|
||||||
|
Text(title!)
|
||||||
|
} else if systemImage != nil {
|
||||||
|
Image(systemName: systemImage!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// simple struct to make it easier to show configurable Alerts
|
||||||
|
// just an Identifiable struct that can create an Alert on demand
|
||||||
|
// use .alert(item: $alertToShow) { theIdentifiableAlert in ... }
|
||||||
|
// where alertToShow is a Binding<IdentifiableAlert>?
|
||||||
|
// then any time you want to show an alert
|
||||||
|
// just set alertToShow = IdentifiableAlert(id: "my alert") { Alert(title: ...) }
|
||||||
|
// of course, the string identifier has to be unique for all your different kinds of alerts
|
||||||
|
|
||||||
|
struct IdentifiableAlert: Identifiable {
|
||||||
|
var id: String
|
||||||
|
var alert: () -> Alert
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user