@craft-native/ios 0.0.235 → 0.0.236
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/package.json +1 -1
- package/templates/CraftNativeScreen.swift +174 -0
package/README.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Build native iOS apps with web technologies using Craft.
|
|
4
4
|
|
|
5
|
+
## Native celebration
|
|
6
|
+
|
|
7
|
+
Any native `View` may opt into a finite card entrance, light sweep and confetti
|
|
8
|
+
burst with `:celebration="{ replay: replayCount, enabled: true }"`. The numeric
|
|
9
|
+
`replay` value identifies the request: unrelated renders keep the current run;
|
|
10
|
+
increment it to replay. `enabled: false` displays an existing card quietly.
|
|
11
|
+
The effect takes three seconds, uses native animation frames, never captures
|
|
12
|
+
touches, and keeps the view's authored transform. iOS Reduce Motion and Android's
|
|
13
|
+
disabled animations show the complete content without movement or confetti.
|
|
14
|
+
Leaving the screen, detaching the view or backgrounding the app cancels the
|
|
15
|
+
decoration. Resuming does not replay it. Available on both native renderers.
|
|
16
|
+
|
|
5
17
|
## Features
|
|
6
18
|
|
|
7
19
|
### Core
|
package/package.json
CHANGED
|
@@ -1834,7 +1834,172 @@ extension UIView {
|
|
|
1834
1834
|
}
|
|
1835
1835
|
}
|
|
1836
1836
|
|
|
1837
|
+
/// A finite, opt-in native celebration. Reconciliation with the same replay
|
|
1838
|
+
/// value never restarts it; Reduce Motion always leaves the complete card still.
|
|
1839
|
+
struct CraftNativeCelebrationSpec: Equatable {
|
|
1840
|
+
let replay: Double
|
|
1841
|
+
let enabled: Bool
|
|
1842
|
+
|
|
1843
|
+
init?(_ raw: Any?) {
|
|
1844
|
+
guard let value = raw as? [String: Any],
|
|
1845
|
+
let replay = value["replay"] as? NSNumber,
|
|
1846
|
+
CFGetTypeID(replay) != CFBooleanGetTypeID(),
|
|
1847
|
+
replay.doubleValue.isFinite, replay.doubleValue >= 0 else { return nil }
|
|
1848
|
+
self.replay = replay.doubleValue
|
|
1849
|
+
enabled = value["enabled"] as? Bool != false
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
final class CraftNativeCelebration {
|
|
1854
|
+
private weak var view: UIView?
|
|
1855
|
+
private var request: CraftNativeCelebrationSpec?
|
|
1856
|
+
private var pending = false
|
|
1857
|
+
private var overlay: CALayer?
|
|
1858
|
+
private var sheen: CALayer?
|
|
1859
|
+
private var cleanup: DispatchWorkItem?
|
|
1860
|
+
private var observers: [NSObjectProtocol] = []
|
|
1861
|
+
private(set) var starts = 0
|
|
1862
|
+
var isRunning: Bool { overlay != nil }
|
|
1863
|
+
|
|
1864
|
+
init(view: UIView) {
|
|
1865
|
+
self.view = view
|
|
1866
|
+
for name in [UIApplication.didEnterBackgroundNotification, UIAccessibility.reduceMotionStatusDidChangeNotification] {
|
|
1867
|
+
observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.stop() })
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
deinit {
|
|
1872
|
+
cleanup?.cancel()
|
|
1873
|
+
overlay?.removeFromSuperlayer()
|
|
1874
|
+
sheen?.removeFromSuperlayer()
|
|
1875
|
+
observers.forEach(NotificationCenter.default.removeObserver)
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
func configure(_ raw: Any?) {
|
|
1879
|
+
let next = CraftNativeCelebrationSpec(raw)
|
|
1880
|
+
guard next != request else { return }
|
|
1881
|
+
stop()
|
|
1882
|
+
request = next
|
|
1883
|
+
pending = next?.enabled == true
|
|
1884
|
+
view?.setNeedsLayout()
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
func stop() {
|
|
1888
|
+
pending = false
|
|
1889
|
+
cleanup?.cancel()
|
|
1890
|
+
cleanup = nil
|
|
1891
|
+
view?.layer.removeAnimation(forKey: "craft.celebration.entrance")
|
|
1892
|
+
overlay?.removeFromSuperlayer()
|
|
1893
|
+
overlay = nil
|
|
1894
|
+
sheen?.removeFromSuperlayer()
|
|
1895
|
+
sheen = nil
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
func layout(reduceMotion: Bool = UIAccessibility.isReduceMotionEnabled) {
|
|
1899
|
+
guard pending, let view, let window = view.window,
|
|
1900
|
+
view.bounds.width > 0, view.bounds.height > 0, !view.isHidden else { return }
|
|
1901
|
+
var ancestor: UIView? = view.superview
|
|
1902
|
+
while let parent = ancestor {
|
|
1903
|
+
if parent.isHidden { return }
|
|
1904
|
+
ancestor = parent.superview
|
|
1905
|
+
}
|
|
1906
|
+
pending = false
|
|
1907
|
+
guard !reduceMotion else { return }
|
|
1908
|
+
starts += 1
|
|
1909
|
+
let frame = view.convert(view.bounds, to: window)
|
|
1910
|
+
let overlay = CALayer()
|
|
1911
|
+
overlay.name = "craft.celebration.confetti"
|
|
1912
|
+
overlay.frame = window.bounds
|
|
1913
|
+
window.layer.addSublayer(overlay)
|
|
1914
|
+
self.overlay = overlay
|
|
1915
|
+
|
|
1916
|
+
// Core Animation owns every frame. No script timers, per-frame bridge
|
|
1917
|
+
// messages, bitmap assets or touch-intercepting overlay views.
|
|
1918
|
+
let colors: [UIColor] = [.init(red: 0.23, green: 0.51, blue: 0.96, alpha: 1), .init(red: 0.6, green: 0.96, blue: 0.89, alpha: 1), .init(red: 0.9, green: 0.77, blue: 0.55, alpha: 1), .white]
|
|
1919
|
+
let now = CACurrentMediaTime()
|
|
1920
|
+
for index in 0..<52 {
|
|
1921
|
+
let side: CGFloat = index % 2 == 0 ? -1 : 1
|
|
1922
|
+
let spread = CGFloat((index * 37) % 101) / 100
|
|
1923
|
+
let start = CGPoint(x: frame.midX + side * frame.width * 0.42, y: frame.minY + 28)
|
|
1924
|
+
let end = CGPoint(x: frame.midX + side * (35 + spread * window.bounds.width * 0.7), y: window.bounds.height + 30)
|
|
1925
|
+
let piece = CALayer()
|
|
1926
|
+
piece.bounds = CGRect(x: 0, y: 0, width: index % 3 == 0 ? 4 : 7, height: 11)
|
|
1927
|
+
piece.cornerRadius = 1
|
|
1928
|
+
piece.backgroundColor = colors[index % colors.count].cgColor
|
|
1929
|
+
piece.position = end
|
|
1930
|
+
piece.opacity = 0
|
|
1931
|
+
overlay.addSublayer(piece)
|
|
1932
|
+
let path = CAKeyframeAnimation(keyPath: "position")
|
|
1933
|
+
path.values = [NSValue(cgPoint: start), NSValue(cgPoint: CGPoint(x: start.x + side * (45 + spread * 65), y: max(0, start.y - 70 - spread * 100))), NSValue(cgPoint: end)]
|
|
1934
|
+
path.keyTimes = [0, 0.28, 1]
|
|
1935
|
+
path.calculationMode = .cubic
|
|
1936
|
+
let tumble = CABasicAnimation(keyPath: "transform.rotation")
|
|
1937
|
+
tumble.fromValue = side * Double(index)
|
|
1938
|
+
tumble.toValue = side * (Double(index) + 7 + Double(spread) * 10)
|
|
1939
|
+
let fade = CAKeyframeAnimation(keyPath: "opacity")
|
|
1940
|
+
fade.values = [0, 1, 1, 0]
|
|
1941
|
+
fade.keyTimes = [0, 0.04, 0.7, 1]
|
|
1942
|
+
let group = CAAnimationGroup()
|
|
1943
|
+
group.animations = [path, tumble, fade]
|
|
1944
|
+
group.duration = 2.2 + Double(spread) * 0.45
|
|
1945
|
+
group.beginTime = now + Double(index % 7) * 0.018
|
|
1946
|
+
group.fillMode = .backwards
|
|
1947
|
+
piece.add(group, forKey: "craft.celebration.particle")
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
let entrance = CASpringAnimation(keyPath: "transform")
|
|
1951
|
+
var from = CATransform3DMakeTranslation(0, 34, 0)
|
|
1952
|
+
from = CATransform3DRotate(from, -0.045, 0, 0, 1)
|
|
1953
|
+
from = CATransform3DScale(from, 0.86, 0.86, 1)
|
|
1954
|
+
entrance.fromValue = NSValue(caTransform3D: CATransform3DConcat(view.layer.transform, from))
|
|
1955
|
+
entrance.toValue = NSValue(caTransform3D: view.layer.transform)
|
|
1956
|
+
entrance.damping = 19
|
|
1957
|
+
entrance.stiffness = 160
|
|
1958
|
+
entrance.mass = 1
|
|
1959
|
+
entrance.duration = 0.95
|
|
1960
|
+
view.layer.add(entrance, forKey: "craft.celebration.entrance")
|
|
1961
|
+
|
|
1962
|
+
let sheen = CAGradientLayer()
|
|
1963
|
+
sheen.name = "craft.celebration.sheen"
|
|
1964
|
+
sheen.frame = view.bounds
|
|
1965
|
+
sheen.colors = [UIColor.clear.cgColor, UIColor.white.withAlphaComponent(0.2).cgColor, UIColor.clear.cgColor]
|
|
1966
|
+
sheen.locations = [0.25, 0.5, 0.75]
|
|
1967
|
+
sheen.startPoint = CGPoint(x: 0, y: 0.3)
|
|
1968
|
+
sheen.endPoint = CGPoint(x: 1, y: 0.7)
|
|
1969
|
+
sheen.opacity = 0
|
|
1970
|
+
let mask = CAShapeLayer()
|
|
1971
|
+
mask.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: view.layer.cornerRadius).cgPath
|
|
1972
|
+
sheen.mask = mask
|
|
1973
|
+
view.layer.addSublayer(sheen)
|
|
1974
|
+
self.sheen = sheen
|
|
1975
|
+
let sweep = CABasicAnimation(keyPath: "transform.translation.x")
|
|
1976
|
+
sweep.fromValue = -view.bounds.width
|
|
1977
|
+
sweep.toValue = view.bounds.width
|
|
1978
|
+
let light = CAKeyframeAnimation(keyPath: "opacity")
|
|
1979
|
+
light.values = [0, 1, 1, 0]
|
|
1980
|
+
light.keyTimes = [0, 0.15, 0.8, 1]
|
|
1981
|
+
let shine = CAAnimationGroup()
|
|
1982
|
+
shine.animations = [sweep, light]
|
|
1983
|
+
shine.duration = 1.15
|
|
1984
|
+
shine.beginTime = now + 0.55
|
|
1985
|
+
sheen.add(shine, forKey: "craft.celebration.sweep")
|
|
1986
|
+
let cleanup = DispatchWorkItem { [weak self] in self?.stop() }
|
|
1987
|
+
self.cleanup = cleanup
|
|
1988
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: cleanup)
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1837
1992
|
private class CraftNativeFlowView: UIStackView {
|
|
1993
|
+
var celebration: CraftNativeCelebration?
|
|
1994
|
+
func configureCelebration(_ raw: Any?) {
|
|
1995
|
+
if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
|
|
1996
|
+
celebration?.configure(raw)
|
|
1997
|
+
}
|
|
1998
|
+
override func didMoveToWindow() {
|
|
1999
|
+
super.didMoveToWindow()
|
|
2000
|
+
if window == nil { celebration?.stop() }
|
|
2001
|
+
else { setNeedsLayout() }
|
|
2002
|
+
}
|
|
1838
2003
|
var pressActiveOpacity: CGFloat?
|
|
1839
2004
|
var pressBaseOpacity: CGFloat = 1
|
|
1840
2005
|
var pressUnderlayColor: UIColor?
|
|
@@ -2051,6 +2216,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2051
2216
|
override func layoutSubviews() {
|
|
2052
2217
|
super.layoutSubviews()
|
|
2053
2218
|
craftApplyCornerRadius()
|
|
2219
|
+
celebration?.layout()
|
|
2054
2220
|
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
2055
2221
|
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
2056
2222
|
var moved = false
|
|
@@ -4134,6 +4300,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4134
4300
|
|
|
4135
4301
|
override func viewWillDisappear(_ animated: Bool) {
|
|
4136
4302
|
super.viewWillDisappear(animated)
|
|
4303
|
+
stopCelebrations(in: view)
|
|
4137
4304
|
// The accessory belongs to the visible native screen. Clear the
|
|
4138
4305
|
// shared shell state before a hybrid/web route appears, otherwise a
|
|
4139
4306
|
// destination from the outgoing screen remains tappable on top of it.
|
|
@@ -4188,6 +4355,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4188
4355
|
/// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
|
|
4189
4356
|
func screenVisibilityChanged(_ visible: Bool) {
|
|
4190
4357
|
guard !runtimeDisposed else { return }
|
|
4358
|
+
if !visible, isViewLoaded { stopCelebrations(in: view) }
|
|
4191
4359
|
// Hidden tab stacks receive no UIKit appearance callback. A cancelled
|
|
4192
4360
|
// preview also restores a screen that never lost focus, so restore its
|
|
4193
4361
|
// chrome even when no new SCREEN_FOCUS event is needed.
|
|
@@ -6217,9 +6385,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6217
6385
|
if disabled { result.isUserInteractionEnabled = false }
|
|
6218
6386
|
}
|
|
6219
6387
|
applyAccessibility(props, type: type, to: result)
|
|
6388
|
+
(result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
|
|
6220
6389
|
return current
|
|
6221
6390
|
}
|
|
6222
6391
|
|
|
6392
|
+
private func stopCelebrations(in root: UIView) {
|
|
6393
|
+
(root as? CraftNativeFlowView)?.celebration?.stop()
|
|
6394
|
+
root.subviews.forEach { stopCelebrations(in: $0) }
|
|
6395
|
+
}
|
|
6396
|
+
|
|
6223
6397
|
private func makeView(_ type: String, props: [String: Any]) -> UIView {
|
|
6224
6398
|
switch type {
|
|
6225
6399
|
case "Text":
|