feat(view, viewmodel, model): 增加 document 和 palette,增加手势

1. 可以从另一个 app 中拖入图片或图片链接作为背景
2. 可以从 palette 中拖入 emoji
3. 可以拖动和双击缩放背景图

Signed-off-by: Ching <loooching@gmail.com>
This commit is contained in:
Ching 2023-02-20 21:45:33 +08:00
parent b6d09f314e
commit 89d38faea4
10 changed files with 684 additions and 31 deletions

View File

@ -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
View 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
}

View File

@ -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()
}
}

View File

@ -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)
} }
} }
} }

View 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
}
}
}

View 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())
}
}

View 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
}
}
}
}

View 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))
}
}

View 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)
}
}

View 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
}