add Character animations
This commit is contained in:
BIN
ios/Apps/Sybil/Resources/Character/character-busy.gif
Normal file
BIN
ios/Apps/Sybil/Resources/Character/character-busy.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
ios/Apps/Sybil/Resources/Character/character-idle.gif
Normal file
BIN
ios/Apps/Sybil/Resources/Character/character-idle.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1,3 +1,4 @@
|
|||||||
|
import ImageIO
|
||||||
import Observation
|
import Observation
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -25,7 +26,7 @@ struct SybilWorkspaceView: View {
|
|||||||
@State private var newChatSwipeDidTriggerHaptic = false
|
@State private var newChatSwipeDidTriggerHaptic = false
|
||||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||||
|
|
||||||
private let customChatNavigationContentInset: CGFloat = 88
|
private let customChatNavigationContentInset: CGFloat = 96
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
@@ -124,6 +125,8 @@ struct SybilWorkspaceView: View {
|
|||||||
workspaceContentStack
|
workspaceContentStack
|
||||||
|
|
||||||
if showsCustomChatNavigation {
|
if showsCustomChatNavigation {
|
||||||
|
SybilChatCharacterBackdrop(isBusy: viewModel.isSending)
|
||||||
|
.allowsHitTesting(false)
|
||||||
customChatNavigationBar
|
customChatNavigationBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -980,6 +983,173 @@ private struct SybilNavigationFadeBackground: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SybilChatCharacterBackdrop: View {
|
||||||
|
var isBusy: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
SybilTheme.primary.opacity(0.36),
|
||||||
|
SybilTheme.primary.opacity(0.13),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 10,
|
||||||
|
endRadius: 150
|
||||||
|
)
|
||||||
|
.frame(width: 136, height: 118)
|
||||||
|
.blur(radius: 7)
|
||||||
|
.offset(x: 28, y: -24)
|
||||||
|
|
||||||
|
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
||||||
|
.frame(width: 172, height: 172)
|
||||||
|
.opacity(0.92)
|
||||||
|
.mask {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.black,
|
||||||
|
Color.black,
|
||||||
|
Color.black.opacity(0)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.mask {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.clear,
|
||||||
|
Color.black.opacity(0.74),
|
||||||
|
Color.black
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.offset(x: 8, y: 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
||||||
|
.clipped()
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
||||||
|
var resourceName: String
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UIImageView {
|
||||||
|
let imageView = SybilAnimatedUIImageView()
|
||||||
|
imageView.backgroundColor = .clear
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = false
|
||||||
|
imageView.isOpaque = false
|
||||||
|
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||||
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
|
return imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ imageView: UIImageView, context: Context) {
|
||||||
|
guard context.coordinator.resourceName != resourceName else {
|
||||||
|
if !imageView.isAnimating {
|
||||||
|
imageView.startAnimating()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.coordinator.resourceName = resourceName
|
||||||
|
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
||||||
|
imageView.startAnimating()
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator {
|
||||||
|
var resourceName: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class SybilAnimatedUIImageView: UIImageView {
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private enum SybilAnimatedGIFCache {
|
||||||
|
private static var images: [String: UIImage] = [:]
|
||||||
|
|
||||||
|
static func image(named name: String) -> UIImage? {
|
||||||
|
if let cached = images[name] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let image = loadImage(named: name) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
images[name] = image
|
||||||
|
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadImage(named name: String) -> UIImage? {
|
||||||
|
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
||||||
|
Bundle.main.url(forResource: name, withExtension: "gif")
|
||||||
|
|
||||||
|
guard let url,
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameCount = CGImageSourceGetCount(source)
|
||||||
|
guard frameCount > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var frames: [UIImage] = []
|
||||||
|
frames.reserveCapacity(frameCount)
|
||||||
|
var duration: TimeInterval = 0
|
||||||
|
|
||||||
|
for index in 0 ..< frameCount {
|
||||||
|
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frames.append(UIImage(cgImage: cgImage))
|
||||||
|
duration += frameDuration(at: index, source: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !frames.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if frames.count == 1 {
|
||||||
|
return frames[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
||||||
|
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
||||||
|
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
||||||
|
else {
|
||||||
|
return 0.08
|
||||||
|
}
|
||||||
|
|
||||||
|
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
||||||
|
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
||||||
|
return max(delay, 0.03)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct NewChatSwipeBackdrop: View {
|
private struct NewChatSwipeBackdrop: View {
|
||||||
var progress: CGFloat
|
var progress: CGFloat
|
||||||
var hasLatched: Bool
|
var hasLatched: Bool
|
||||||
|
|||||||
Reference in New Issue
Block a user