From 89d38faea4e569ffb40517c9d0afd9bc0c7b17de Mon Sep 17 00:00:00 2001 From: Ching Date: Mon, 20 Feb 2023 21:45:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(view,=20viewmodel,=20model):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20document=20=E5=92=8C=20palette=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=89=8B=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 可以从另一个 app 中拖入图片或图片链接作为背景 2. 可以从 palette 中拖入 emoji 3. 可以拖动和双击缩放背景图 Signed-off-by: Ching --- EmojiArt.xcodeproj/project.pbxproj | 32 ++- EmojiArt/Constants.swift | 14 ++ EmojiArt/ContentView.swift | 26 --- EmojiArt/EmojiArtApp.swift | 3 +- EmojiArt/EmojiArtDocument.swift | 80 ++++++++ EmojiArt/EmojiArtDocumentView.swift | 171 ++++++++++++++++ EmojiArt/EmojiArtModel.Background.swift | 30 +++ EmojiArt/EmojiArtModel.swift | 40 ++++ EmojiArt/UtilityExtensions.swift | 254 ++++++++++++++++++++++++ EmojiArt/UtilityViews.swift | 65 ++++++ 10 files changed, 684 insertions(+), 31 deletions(-) create mode 100644 EmojiArt/Constants.swift delete mode 100644 EmojiArt/ContentView.swift create mode 100644 EmojiArt/EmojiArtDocument.swift create mode 100644 EmojiArt/EmojiArtDocumentView.swift create mode 100644 EmojiArt/EmojiArtModel.Background.swift create mode 100644 EmojiArt/EmojiArtModel.swift create mode 100644 EmojiArt/UtilityExtensions.swift create mode 100644 EmojiArt/UtilityViews.swift diff --git a/EmojiArt.xcodeproj/project.pbxproj b/EmojiArt.xcodeproj/project.pbxproj index fc17cd6..b8c9075 100644 --- a/EmojiArt.xcodeproj/project.pbxproj +++ b/EmojiArt.xcodeproj/project.pbxproj @@ -8,18 +8,30 @@ /* Begin PBXBuildFile section */ 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 */; }; 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 */ /* Begin PBXFileReference section */ 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 = ""; }; - 24145E7D29A1489700ECB9D1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 24145E7D29A1489700ECB9D1 /* EmojiArtDocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtDocumentView.swift; sourceTree = ""; }; 24145E7F29A1489800ECB9D1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 24145E8129A1489800ECB9D1 /* EmojiArt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EmojiArt.entitlements; sourceTree = ""; }; 24145E8329A1489800ECB9D1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 24145E8A29A1498500ECB9D1 /* EmojiArtModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtModel.swift; sourceTree = ""; }; + 24145E8C29A225E900ECB9D1 /* EmojiArtModel.Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtModel.Background.swift; sourceTree = ""; }; + 248D14EB29A227C700AE4C0D /* EmojiArtDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiArtDocument.swift; sourceTree = ""; }; + 248D14EF29A230FE00AE4C0D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 248D14F129A2435000AE4C0D /* UtilityViews.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UtilityViews.swift; sourceTree = ""; }; + 248D14F529A243F200AE4C0D /* UtilityExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UtilityExtensions.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -52,8 +64,14 @@ 24145E7A29A1489700ECB9D1 /* EmojiArt */ = { isa = PBXGroup; children = ( + 248D14EF29A230FE00AE4C0D /* Constants.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 */, 24145E8129A1489800ECB9D1 /* EmojiArt.entitlements */, 24145E8229A1489800ECB9D1 /* Preview Content */, @@ -139,8 +157,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 24145E7E29A1489700ECB9D1 /* ContentView.swift in Sources */, + 24145E7E29A1489700ECB9D1 /* EmojiArtDocumentView.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; }; diff --git a/EmojiArt/Constants.swift b/EmojiArt/Constants.swift new file mode 100644 index 0000000..a1907e2 --- /dev/null +++ b/EmojiArt/Constants.swift @@ -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 +} diff --git a/EmojiArt/ContentView.swift b/EmojiArt/ContentView.swift deleted file mode 100644 index 99f3a8b..0000000 --- a/EmojiArt/ContentView.swift +++ /dev/null @@ -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() - } -} diff --git a/EmojiArt/EmojiArtApp.swift b/EmojiArt/EmojiArtApp.swift index e993ad6..5bad7f8 100644 --- a/EmojiArt/EmojiArtApp.swift +++ b/EmojiArt/EmojiArtApp.swift @@ -9,9 +9,10 @@ import SwiftUI @main struct EmojiArtApp: App { + let document = EmojiArtDocument() var body: some Scene { WindowGroup { - ContentView() + EmojiArtDocumentView(document: document) } } } diff --git a/EmojiArt/EmojiArtDocument.swift b/EmojiArt/EmojiArtDocument.swift new file mode 100644 index 0000000..dcc3745 --- /dev/null +++ b/EmojiArt/EmojiArtDocument.swift @@ -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 + } + } +} diff --git a/EmojiArt/EmojiArtDocumentView.swift b/EmojiArt/EmojiArtDocumentView.swift new file mode 100644 index 0000000..a18dadc --- /dev/null +++ b/EmojiArt/EmojiArtDocumentView.swift @@ -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()) + } +} diff --git a/EmojiArt/EmojiArtModel.Background.swift b/EmojiArt/EmojiArtModel.Background.swift new file mode 100644 index 0000000..6b5b7d7 --- /dev/null +++ b/EmojiArt/EmojiArtModel.Background.swift @@ -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 + } + } + } +} diff --git a/EmojiArt/EmojiArtModel.swift b/EmojiArt/EmojiArtModel.swift new file mode 100644 index 0000000..98fd475 --- /dev/null +++ b/EmojiArt/EmojiArtModel.swift @@ -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)) + } + +} diff --git a/EmojiArt/UtilityExtensions.swift b/EmojiArt/UtilityExtensions.swift new file mode 100644 index 0000000..7c91931 --- /dev/null +++ b/EmojiArt/UtilityExtensions.swift @@ -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(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(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(ofType theType: T.Type, using load: @escaping (T) -> Void) -> Bool where T: NSItemProviderReading { + loadObjects(ofType: theType, firstOnly: true, using: load) + } + func loadFirstObject(ofType theType: T.Type, using load: @escaping (T) -> Void) -> Bool where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading { + loadObjects(ofType: theType, firstOnly: true, using: load) + } +} diff --git a/EmojiArt/UtilityViews.swift b/EmojiArt/UtilityViews.swift new file mode 100644 index 0000000..f9e5a42 --- /dev/null +++ b/EmojiArt/UtilityViews.swift @@ -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? +// 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 +}