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 */
|
||||
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 = "<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>"; };
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* 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;
|
||||
};
|
||||
|
||||
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
|
||||
struct EmojiArtApp: App {
|
||||
let document = EmojiArtDocument()
|
||||
var body: some Scene {
|
||||
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