From 1b75d9369757657686273492e485d2cf78613d2d Mon Sep 17 00:00:00 2001 From: Satoshi SAKAO Date: Tue, 28 Nov 2023 23:11:34 +0900 Subject: [PATCH] init --- .gitignore | 8 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + Package.swift | 22 +++ Sources/Confetti/ConfettiScene.swift | 158 ++++++++++++++++++ Sources/Confetti/ConfettiView.swift | 29 ++++ Sources/Confetti/UIColor+HexCode.swift | 22 +++ Tests/ConfettiTests/ConfettiTests.swift | 7 + .../ConfettiTests/UIColor+HexCodeTests.swift | 44 +++++ 8 files changed, 298 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Package.swift create mode 100644 Sources/Confetti/ConfettiScene.swift create mode 100644 Sources/Confetti/ConfettiView.swift create mode 100644 Sources/Confetti/UIColor+HexCode.swift create mode 100644 Tests/ConfettiTests/ConfettiTests.swift create mode 100644 Tests/ConfettiTests/UIColor+HexCodeTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..86b4396 --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Confetti", + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: "Confetti", + targets: ["Confetti"]), + ], + targets: [ + .target( + name: "Confetti"), + .testTarget( + name: "ConfettiTests", + dependencies: ["Confetti"]), + ] +) diff --git a/Sources/Confetti/ConfettiScene.swift b/Sources/Confetti/ConfettiScene.swift new file mode 100644 index 0000000..14c47c9 --- /dev/null +++ b/Sources/Confetti/ConfettiScene.swift @@ -0,0 +1,158 @@ +import Foundation +import SpriteKit + +class ConfettiScene: SKScene { + /// Confetti emission duration in seconds. + /// Duration for all confetto to fall isn't controllable. It's depends on confetto falling speed that are random. + var emissionDuration: Double! + + // emission rate per seconds + private let emissionRate = 120.0 + // max angle from straight down (270 degree) in radians + private let maxDirectionAngle = Double.pi / 4 + // color: new year confetti https://www.schemecolor.com/new-year-confetti.php + private let colors = [SKColor(hex: "dc4353")!, SKColor(hex: "e8d4b4")!, SKColor(hex: "de45ba")!, SKColor(hex: "3a378b")!, SKColor(hex: "66d3e1")!] + // debug mode + private let debug = false + // label for debug + private var nodeCountLabel: SKLabelNode! + + // timer for emission + private var emissionTimer: Timer? + + convenience init(size: CGSize, emissionDuration: Double) { + self.init(size: size) + self.emissionDuration = emissionDuration + } + + // generate random confetti size + private func randomSize() -> CGSize { + let longSide = [18.0, 22.0, 25.0].randomElement()! + let aspectRatio = [0.5, 0.4, 0.3].randomElement()! + return CGSize(width: longSide, height: longSide * aspectRatio) + } + + // generate random confetti direction + private func randomDirection() -> Double { + Double.random(in: (Double.pi * 1.5 - maxDirectionAngle) ... (Double.pi * 1.5 + maxDirectionAngle)) + } + + // generate random confetti rotation speed + private func randomRotationSpeed() -> Double { + Double.random(in: 0.3...4.0) * [-1, 1].randomElement()! + } + + // generate random confetti scale speed + private func randomScaleSpeed() -> Double { + Double.random(in: 0.8...1.3) + } + + // generate random confetti color + private func randomColor() -> SKColor { + colors.randomElement()! + } + + // generage random confetti initial position + private func randomInitialPosition(viewSize: CGSize) -> CGPoint { + let maxXMovement = viewSize.height * sin(maxDirectionAngle) + let x = Double.random(in: -maxXMovement ... (viewSize.width + maxXMovement)) + // FIXME: 固定値(10)ではなくノードの回転を考慮した取りうる最大高さを計算して足す + let y = Double.random(in: (viewSize.height + 10) ... viewSize.height * 1.2) + return CGPoint(x: x, y: y) + } + + override func update(_ currentTime: TimeInterval) { + // remove fallen confetto + for node in children { + if node.position.y < -50 { + node.removeAllActions() + node.removeFromParent() + } + } + + // update label for debug + if debug { + nodeCountLabel.text = "confetti count: \(children.count - 1)" + } + } + + override func didMove(to view: SKView) { + // make background transparent + backgroundColor = .clear + view.allowsTransparency = true + view.backgroundColor = .clear + + // set confetti emission timer + emissionTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / emissionRate, repeats: true) { timer in + // create random confetti and add it to scene + let confettiNode = self.createConfettiNode( + color: self.randomColor(), + size: self.randomSize(), + direction: self.randomDirection(), + rotationSpeedX: self.randomRotationSpeed(), + rotationSpeedY: self.randomRotationSpeed(), + rotationSpeedZ: self.randomRotationSpeed(), + scaleSpeed: self.randomScaleSpeed()) + confettiNode.position = self.randomInitialPosition(viewSize: view.frame.size) + self.addChild(confettiNode) + } + + // finish emisison after `emissionDuration` sec + Timer.scheduledTimer(withTimeInterval: emissionDuration, repeats: false) { timer in + self.emissionTimer?.invalidate() + } + + // put label for debug + if debug { + nodeCountLabel = SKLabelNode(text: "") + nodeCountLabel.position = CGPoint(x: 50, y: 50) + nodeCountLabel.fontColor = .blue + addChild(nodeCountLabel) + } + } + + // create confetti node + private func createConfettiNode(color: SKColor, size: CGSize, direction: Double, + rotationSpeedX: Double, rotationSpeedY: Double, rotationSpeedZ: Double, scaleSpeed: Double) -> SKNode { + + let node = SKShapeNode(path: .init(rect: CGRect(origin: .zero, size: size), transform: nil), centered: true) + node.fillColor = color + node.strokeColor = .clear + + // wrapping node for x-rotation and y-rotation + let transformNode = SKTransformNode() + transformNode.addChild(node) + + // x-rotation action + let rotationActionX = SKAction.customAction(withDuration: abs(rotationSpeedX)) { (node: SKNode, time: CGFloat) -> Void in + (node as! SKTransformNode).xRotation = (time / rotationSpeedX) * 2 * CGFloat(Double.pi) + } + + // y-rotation action + let rotationActionY = SKAction.customAction(withDuration: abs(rotationSpeedY)) { (node: SKNode, time: CGFloat) -> Void in + (node as! SKTransformNode).yRotation = (time / rotationSpeedY) * 2 * CGFloat(Double.pi) + } + + // z-rotation action + let rotationActionZ = SKAction.customAction(withDuration: abs(rotationSpeedZ)) { (node: SKNode, time: CGFloat) -> Void in + (node as! SKTransformNode).zRotation = (time / rotationSpeedZ) * 2 * CGFloat(Double.pi) + } + + // move action + // biger(near) faster, smaller(far) slower + let moveSpeed = pow(size.width, 1.2) * 8 + let moveAction = SKAction.move(by: CGVector(dx: cos(direction) * moveSpeed, dy: sin(direction) * moveSpeed), duration: 1.0) + + // scale action + let scaleAction = SKAction.scale(by: scaleSpeed, duration: 1.0) + + // add actions to node + transformNode.run(SKAction.repeatForever(rotationActionX)) + transformNode.run(SKAction.repeatForever(rotationActionY)) + transformNode.run(SKAction.repeatForever(rotationActionZ)) + transformNode.run(SKAction.repeatForever(moveAction)) + transformNode.run(SKAction.repeatForever(scaleAction)) + + return transformNode + } +} diff --git a/Sources/Confetti/ConfettiView.swift b/Sources/Confetti/ConfettiView.swift new file mode 100644 index 0000000..46ae326 --- /dev/null +++ b/Sources/Confetti/ConfettiView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import SpriteKit + +public struct ConfettiView: View { + // Confetti emission duration in seconds. + private var emissionDuration: Double + + /// Creates a new confetti view. + /// - Parameters: + /// - emissionDuration: Confetti emission duration in seconds. Duration for all confetto to fall isn't controllable. It's depends on confetto falling speed that are random. + public init(emissionDuration: Double = 2.0) { + self.emissionDuration = emissionDuration + } + + public var body: some View { + GeometryReader { + SpriteView(scene: ConfettiScene(size: $0.size, emissionDuration: emissionDuration), options: [.allowsTransparency]) + .background(.clear) + .ignoresSafeArea() + .allowsHitTesting(false) + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ConfettiView(emissionDuration: 120.0) + } +} diff --git a/Sources/Confetti/UIColor+HexCode.swift b/Sources/Confetti/UIColor+HexCode.swift new file mode 100644 index 0000000..4ce623c --- /dev/null +++ b/Sources/Confetti/UIColor+HexCode.swift @@ -0,0 +1,22 @@ +import UIKit + +extension UIColor { + /// create new object with hex string + convenience init?(hex: String, alpha: CGFloat = 1.0) { + // delete "#" prefix + let hexNorm = hex.hasPrefix("#") ? String(hex.dropFirst(1)) : hex + + // scan each byte of RGB respectively + let scanner = Scanner(string: hexNorm) + var color: UInt64 = 0 + if scanner.scanHexInt64(&color) { + let red = CGFloat((color & 0xFF0000) >> 16) / 255.0 + let green = CGFloat((color & 0x00FF00) >> 8) / 255.0 + let blue = CGFloat(color & 0x0000FF) / 255.0 + self.init(red: red, green: green, blue: blue, alpha: alpha) + } else { + // invalid format + return nil + } + } +} diff --git a/Tests/ConfettiTests/ConfettiTests.swift b/Tests/ConfettiTests/ConfettiTests.swift new file mode 100644 index 0000000..1a2e89a --- /dev/null +++ b/Tests/ConfettiTests/ConfettiTests.swift @@ -0,0 +1,7 @@ +// import XCTest +// @testable import Confetti +// +// final class ConfettiTests: XCTestCase { +// func testExample() throws { +// } +// } diff --git a/Tests/ConfettiTests/UIColor+HexCodeTests.swift b/Tests/ConfettiTests/UIColor+HexCodeTests.swift new file mode 100644 index 0000000..5603a02 --- /dev/null +++ b/Tests/ConfettiTests/UIColor+HexCodeTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Confetti + +final class UIColorHexCodeTests: XCTestCase { + var red, green, blue, alpha: CGFloat! + + override func setUp() { + red = 0 + green = 0 + blue = 0 + alpha = 0 + } + + func testWhite() throws { + let color = UIColor(hex: "FFFFFF")! + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + XCTAssertEqual(red, 1.0) + XCTAssertEqual(green, 1.0) + XCTAssertEqual(blue, 1.0) + XCTAssertEqual(alpha, 1.0) + } + + func testBlack() throws { + let color = UIColor(hex: "000000")! + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + XCTAssertEqual(red, 0.0) + XCTAssertEqual(green, 0.0) + XCTAssertEqual(blue, 0.0) + XCTAssertEqual(alpha, 1.0) + } + + func testSkyBlue() throws { + let color = UIColor(hex: "#3366cc", alpha: 0.9)! + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + XCTAssertEqual(red, 0.2) + XCTAssertEqual(green, 0.4) + XCTAssertEqual(blue, 0.8) + XCTAssertEqual(alpha, 0.9) + } + + func testInvalidFormat() throws { + XCTAssertNil(UIColor(hex: "white")) + } +}