1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643
|
// Copyright (C) 2024-2025 Apple Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
// 1. Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// 2. Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
// BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
// THE POSSIBILITY OF SUCH DAMAGE.
import Foundation
#if canImport(WritingTools)
#if canImport(AppKit)
import AppKit
// WritingToolsUI is not present in the base system, but WebKit is, so it must be weak-linked.
// WritingToolsUI need not be soft-linked from WebKitSwift because although WTUI links WebKit, WebKit does not directly link WebKitSwift.
@_weakLinked internal import WritingToolsUI_Private._WTTextEffectView
@_weakLinked internal import WritingToolsUI_Private._WTSweepTextEffect
@_weakLinked internal import WritingToolsUI_Private._WTReplaceTextEffect
#else
internal import UIKit_Private
@_spi(TextEffects) import UIKit
#endif
import WebKitSwift
// Work around rdar://145157171 by manually importing the cross-import module.
#if canImport(_WebKit_SwiftUI)
internal import _WebKit_SwiftUI
#endif
internal import SwiftUI
// MARK: Platform abstraction type aliases
#if canImport(AppKit)
typealias PlatformView = NSView
typealias PlatformBounds = NSRect
typealias PlatformTextPreview = [_WTTextPreview]
#else
typealias PlatformView = UIView
typealias PlatformBounds = CGRect
typealias PlatformTextPreview = UITargetedPreview
#endif
struct PlatformContentPreview {
let previewImage: CGImage?
let presentationFrame: CGRect
}
// MARK: Platform abstraction protocols
/// Some arbitrary data which can be translated to and represented by a text preview,
/// and also be able to be identified.
protocol PlatformIntelligenceTextEffectChunk: Identifiable {
}
/// Either a pondering or replacement effect.
@MainActor protocol PlatformIntelligenceTextEffect<Chunk>: Equatable, Identifiable where ID == PlatformIntelligenceTextEffectID {
associatedtype Chunk: PlatformIntelligenceTextEffectChunk
var chunk: Chunk { get }
// Clients should not invoke this function directly.
func _add<Source>(to view: PlatformIntelligenceTextEffectView<Source>) async where Source: PlatformIntelligenceTextEffectViewSource, Source.Chunk == Chunk
}
extension PlatformIntelligenceTextEffect {
nonisolated static func ==(lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
}
/// A combination source+delegate protocol that clients conform to to control behavior and yield information to the effect view.
@MainActor protocol PlatformIntelligenceTextEffectViewSource: AnyObject {
associatedtype Chunk: PlatformIntelligenceTextEffectChunk
/// Transforms an arbitrary chunk into a text preview.
func textPreview(for chunk: Chunk) async -> PlatformTextPreview?
/// Controls the visibility of text associated with the specified chunk.
func updateTextChunkVisibility(_ chunk: Chunk, visible: Bool) async
/// In the implementation of this method, clients should replace the backing text storage (which mustn't be visible to the user).
/// Then, a preview of the resulting text should be created.
///
/// Clients must also take the responsibility of animating the remaining text away from the replaced text if needed, using
/// the provided animation parameters.
func performReplacementAndGeneratePreview(for chunk: Chunk, effect: PlatformIntelligenceReplacementTextEffect<Chunk>) async -> (PlatformTextPreview?, remainder: PlatformContentPreview?)
/// This function is invoked after preparing the replacement effect, but before the effect is added.
func replacementEffectWillBegin(_ effect: PlatformIntelligenceReplacementTextEffect<Chunk>) async
/// This function is invoked once both parts of the replacement effect are complete.
func replacementEffectDidComplete(_ effect: PlatformIntelligenceReplacementTextEffect<Chunk>) async
}
// MARK: Platform type adapters.
#if canImport(UIKit)
@MainActor private final class UITextEffectViewSourceAdapter<Wrapped>: NSObject, UITextEffectViewSource where Wrapped: PlatformIntelligenceTextEffectViewSource {
private var wrapped: Wrapped
init(wrapping wrapped: Wrapped) {
self.wrapped = wrapped
}
// This method needs to be `@objc`, and the type itself needs to conform to `NSObject`, otherwise this method will always return `true`.
//
// This is because internally, UIKit creates a type with a default conformance to this protocol, and a default implementation of this method.
// The default implementation ostensibly requires the real conforming type to be an `NSObject`, and if not will return `true`. And then, if
// it is an `NSObject`, it performs a selector check, which requires an `@objc` implementation, else it will fail and once again return `true`.
@objc func canGenerateTargetedPreviewForChunk(_ chunk: UITextEffectTextChunk) async -> Bool {
if let chunk = chunk as? UIPonderingTextEffectTextChunkAdapter<Wrapped.Chunk> {
return true
}
if let chunk = chunk as? UIReplacementTextEffectTextChunkAdapter<Wrapped.Chunk> {
return chunk.source != nil
}
return false
}
func targetedPreview(for chunk: UITextEffectTextChunk) async -> UITargetedPreview {
if let chunk = chunk as? UIPonderingTextEffectTextChunkAdapter<Wrapped.Chunk> {
return chunk.preview
}
if let chunk = chunk as? UIReplacementTextEffectTextChunkAdapter<Wrapped.Chunk> {
// The chunk source may be `nil` in the case of a replacement whose source range is an empty range.
// This force unwrap is safe because UIKit invokes `canGenerateTargetedPreviewForChunk` prior to this call.
return chunk.source!
}
fatalError("Failed to create a targeted preview: parameter was of unexpected type \(type(of: chunk)).")
}
func updateTextChunkVisibilityForAnimation(_ chunk: UITextEffectTextChunk, visible: Bool) async {
if let chunk = chunk as? UIPonderingTextEffectTextChunkAdapter<Wrapped.Chunk> {
await self.wrapped.updateTextChunkVisibility(chunk.wrapped, visible: visible)
}
if let chunk = chunk as? UIReplacementTextEffectTextChunkAdapter<Wrapped.Chunk> {
await self.wrapped.updateTextChunkVisibility(chunk.wrapped, visible: visible)
}
}
}
@MainActor private final class UIReplacementTextEffectDelegateAdapter<Wrapped>: UITextEffectView.ReplacementTextEffect.Delegate where Wrapped: PlatformIntelligenceTextEffectViewSource {
private let wrapped: Wrapped
private weak var view: PlatformIntelligenceTextEffectView<Wrapped>?
init(wrapping wrapped: Wrapped, view: PlatformIntelligenceTextEffectView<Wrapped>) {
self.wrapped = wrapped
self.view = view
}
func replacementEffectDidComplete(_ effect: UITextEffectView.ReplacementTextEffect) {
guard let view = self.view else {
assertionFailure("Failed to handle completion of replacement effect: view was unexpectedly nil.")
return
}
guard let effect = view.wrappedEffectIDToPlatformEffects[effect.id] as? PlatformIntelligenceReplacementTextEffect<Wrapped.Chunk> else {
assertionFailure("Failed to handle completion of replacement effect: effect was unexpectedly nil.")
return
}
Task { @MainActor in
await self.wrapped.replacementEffectDidComplete(effect)
}
}
func performReplacementAndGeneratePreview(for chunk: UITextEffectTextChunk, effect: UITextEffectView.ReplacementTextEffect, animation: UITextEffectView.ReplacementTextEffect.AnimationParameters) async -> UITargetedPreview? {
guard let chunk = chunk as? UIReplacementTextEffectTextChunkAdapter<Wrapped.Chunk> else {
fatalError("Failed to perform replacement and generate preview: parameter was of unexpected type \(type(of: chunk)).")
}
return chunk.destination
}
}
private final class UIPonderingTextEffectTextChunkAdapter<Wrapped>: UITextEffectTextChunk where Wrapped: PlatformIntelligenceTextEffectChunk {
let wrapped: Wrapped
let preview: UITargetedPreview
init(wrapping wrapped: Wrapped, preview: UITargetedPreview) {
self.wrapped = wrapped
self.preview = preview
}
}
private final class UIReplacementTextEffectTextChunkAdapter<Wrapped>: UITextEffectTextChunk where Wrapped: PlatformIntelligenceTextEffectChunk {
let wrapped: Wrapped
let source: UITargetedPreview?
let destination: UITargetedPreview
init(wrapping wrapped: Wrapped, source: UITargetedPreview?, destination: UITargetedPreview) {
self.wrapped = wrapped
self.source = source
self.destination = destination
}
}
#else
@MainActor private final class WTTextPreviewAsyncSourceAdapter<Wrapped>: NSObject, _WTTextPreviewAsyncSource where Wrapped: PlatformIntelligenceTextEffectViewSource {
private let wrapped: Wrapped
init(wrapping wrapped: Wrapped) {
self.wrapped = wrapped
}
func textPreviews(for chunk: _WTTextChunk) async -> [_WTTextPreview]? {
guard let chunk = chunk as? WTTextChunkAdapter<Wrapped.Chunk> else {
fatalError("Failed to update text chunk visibility: parameter was of unexpected type \(type(of: chunk)).")
}
return chunk.preview
}
func textPreview(for rect: CGRect) async -> _WTTextPreview? {
// This is implemented manually by the system instead of relying on the WTUI interface.
nil
}
func updateIsTextVisible(_ isTextVisible: Bool, for chunk: _WTTextChunk) async {
guard let chunk = chunk as? WTTextChunkAdapter<Wrapped.Chunk> else {
fatalError("Failed to update text chunk visibility: parameter was of unexpected type \(type(of: chunk)).")
}
await self.wrapped.updateTextChunkVisibility(chunk.wrapped, visible: isTextVisible)
}
}
private final class WTTextChunkAdapter<Wrapped>: _WTTextChunk where Wrapped: PlatformIntelligenceTextEffectChunk {
let wrapped: Wrapped
let preview: PlatformTextPreview?
init(wrapping wrapped: Wrapped, preview: PlatformTextPreview?) {
self.wrapped = wrapped
self.preview = preview
super.init(chunkWithIdentifier: UUID().uuidString)
}
}
#endif
// MARK: Platform abstraction types
/// An opaque identifier for effects.
struct PlatformIntelligenceTextEffectID: Hashable {
private let id = UUID()
fileprivate init() {
}
}
/// A platform-agnostic view to control intelligence text effects given a particular source.
@MainActor final class PlatformIntelligenceTextEffectView<Source>: PlatformView where Source: PlatformIntelligenceTextEffectViewSource {
#if canImport(UIKit)
fileprivate typealias SourceAdapter = UITextEffectViewSourceAdapter<Source>
fileprivate typealias Wrapped = UITextEffectView
#else
fileprivate typealias SourceAdapter = WTTextPreviewAsyncSourceAdapter<Source>
fileprivate typealias Wrapped = _WTTextEffectView
#endif
fileprivate let source: Source
fileprivate let wrapped: Wrapped
private let viewSource: SourceAdapter
#if canImport(UIKit)
fileprivate var wrappedEffectIDToPlatformEffects: [UITextEffectView.EffectID : any PlatformIntelligenceTextEffect] = [:]
fileprivate var platformEffectIDToWrappedEffectIDs: [PlatformIntelligenceTextEffectID : UITextEffectView.EffectID] = [:]
#else
fileprivate var wrappedEffectIDToPlatformEffects: [UUID : any PlatformIntelligenceTextEffect<Source.Chunk>] = [:]
fileprivate var platformEffectIDToWrappedEffectIDs: [PlatformIntelligenceTextEffectID : Set<UUID>] = [:]
#endif
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Create a new text effect view.
init(source: Source) {
self.source = source
self.viewSource = SourceAdapter(wrapping: self.source)
#if canImport(UIKit)
self.wrapped = Wrapped(source: self.viewSource)
#else
self.wrapped = Wrapped(asyncSource: self.viewSource)
#endif
self.wrapped.clipsToBounds = true
super.init(frame: .zero)
}
func initializeSubviews() {
self.addSubview(self.wrapped)
self.wrapped.frame = self.bounds
}
/// Prepares and adds an effect to be presented within the view.
@discardableResult func addEffect<Effect>(_ effect: Effect) async -> Effect.ID where Effect: PlatformIntelligenceTextEffect, Effect.Chunk == Source.Chunk {
await effect._add(to: self)
return effect.id
}
/// Removes the effect with the specified id.
func removeEffect(_ effectID: PlatformIntelligenceTextEffectID) async {
guard let wrappedEffectIDs = self.platformEffectIDToWrappedEffectIDs.removeValue(forKey: effectID) else {
return
}
#if canImport(UIKit)
self.wrappedEffectIDToPlatformEffects[wrappedEffectIDs] = nil
self.wrapped.removeEffect(wrappedEffectIDs)
#else
for wrappedEffectID in wrappedEffectIDs {
if let platformEffect = self.wrappedEffectIDToPlatformEffects.removeValue(forKey: wrappedEffectID), platformEffect is PlatformIntelligencePonderingTextEffect<Source.Chunk> {
// When WTUI starts a pondering effect, it creates a 0.75s opacity CA animation to fade out the text, so it is possible
// that this is still ongoing by the time `removeEffect` is called. This may lead to issues if subsequent effects start
// immediately after the effect is removed and the animation has yet to stop.
//
// To workaround this, manually try to find the applicable sublayer that WTUI adds the animation to, and remove it directly.
// FIXME: This is a fragile workaround, and should be removed once WTUI has proper support for removing effects at any point.
for sublayer in self.wrapped.layer?.sublayers ?? [] {
sublayer.removeAnimation(forKey: "opacity")
}
}
self.wrappedEffectIDToPlatformEffects[wrappedEffectID] = nil
self.wrapped.removeEffect(wrappedEffectID)
}
#endif
}
/// Removes all currently active effects.
func removeAllEffects() {
self.wrapped.removeAllEffects()
self.platformEffectIDToWrappedEffectIDs = [:]
self.wrappedEffectIDToPlatformEffects = [:]
}
}
/// An effect which shifts the remaining text (the text after the currently replaced text) either up or down,
/// depending on if the newly replaced text is taller than the source text.
@MainActor class PlatformIntelligenceRemainderAffordanceTextEffect<Chunk>: PlatformIntelligenceTextEffect where Chunk: PlatformIntelligenceTextEffectChunk {
private enum AnimationKind {
case contract
case expand
}
struct Previews {
let source: PlatformTextPreview?
let destination: PlatformTextPreview
let remainder: PlatformContentPreview
}
let id = PlatformIntelligenceTextEffectID()
let chunk: Chunk
let previews: Previews
init(chunk: Chunk, previews: Previews) {
self.chunk = chunk
self.previews = previews
}
private static func animation(for kind: AnimationKind) -> SwiftUI.Animation {
// Empirically derived animation values, specifically ensuring that the text avoids being overlapped by the replacement animation.
switch kind {
case .contract: .easeInOut(duration: 0.4).delay(0.5)
case .expand: .bouncy(duration: 0.4).delay(0.2)
}
}
private func heightDelta() -> Double {
#if canImport(UIKit)
let sourceRect = previews.source?.size ?? .zero
let destRect = previews.destination.size
let delta = destRect.height - sourceRect.height
#else
let sourceRect = (previews.source ?? [])
.map(\.presentationFrame)
.reduce(CGRect.zero) { $0.union($1) }
let destRect = previews.destination
.map(\.presentationFrame)
.reduce(CGRect.zero) { $0.union($1) }
let delta = destRect.size.height - sourceRect.size.height
#endif
return delta
}
func _add<Source>(to view: PlatformIntelligenceTextEffectView<Source>) async where Source : PlatformIntelligenceTextEffectViewSource, Source.Chunk == Chunk {
guard let remainderPreviewImage = previews.remainder.previewImage else {
return
}
// Compute the difference in height between the original text and the replaced text.
let delta = heightDelta()
// Create two rects:
// 1. A source frame for the remainder of the text content before the text is replaced.
// 2. A destination frame for the remainder of the text content after the text is replaced.
//
// The y-coordinates are adjusted for AppKit to account for the origin being the bottom left instead of top left.
let remainderRect = previews.remainder.presentationFrame
#if canImport(UIKit)
let remainderViewSourceFrameY = remainderRect.origin.y
#else
// origin-y-coordinate is flipped in AppKit
let remainderViewSourceFrameY = view.frame.size.height - remainderRect.size.height - remainderRect.origin.y
#endif
let remainderViewSourceFrame = CGRect(
x: remainderRect.origin.x,
y: remainderViewSourceFrameY,
width: remainderRect.size.width,
height: remainderRect.size.height
)
#if canImport(UIKit)
// shift down if the replaced text is taller than the source text
let remainderViewDestFrameY = remainderViewSourceFrame.origin.y + delta
#else
// shift down if the replaced text is taller than the source text
let remainderViewDestFrameY = remainderViewSourceFrame.origin.y - delta
#endif
let remainderViewDestinationFrame = CGRect(
x: remainderViewSourceFrame.origin.x,
y: remainderViewDestFrameY,
width: remainderViewSourceFrame.size.width,
height: remainderViewSourceFrame.size.height
)
// Create an empty view with the source frame, and set its layer's contents to the image
// of the remaining text content.
let remainderView = PlatformView(frame: remainderViewSourceFrame)
#if canImport(UIKit)
remainderView.layer.contents = remainderPreviewImage
#else
remainderView.wantsLayer = true
remainderView.layer!.contents = remainderPreviewImage
#endif
// Add the newly created view as a subview to the effect view.
view.addSubview(remainderView)
// Perform the animation to animate the frame to make room for the replaced text.
// This will run concurrently with the replacement effect, and it must not ever overlap with the effect.
let animation = Self.animation(for: delta > 0 ? .expand : .contract)
let changes = {
remainderView.frame = remainderViewDestinationFrame
}
#if canImport(UIKit)
UIView.animate(animation, changes: changes)
#else
NSAnimationContext.animate(animation, changes: changes)
#endif
}
}
/// A replacement effect, which essentially involves the original text fading away while at the same time the new text fades in right above it.
@MainActor class PlatformIntelligenceReplacementTextEffect<Chunk>: PlatformIntelligenceTextEffect where Chunk: PlatformIntelligenceTextEffectChunk {
let id = PlatformIntelligenceTextEffectID()
let chunk: Chunk
// This is needed to keep track of when the entire replacement effect has completed,
// since it is not guaranteed that the source completion handler is always invoked prior
// to the destination effect completion handler.
private var hasCompletedPartialWrappedEffect = false
init(chunk: Chunk) {
self.chunk = chunk
}
#if canImport(AppKit)
private func didCompletePartialWrappedEffect<Source>(for source: Source) where Source: PlatformIntelligenceTextEffectViewSource, Source.Chunk == Chunk {
if self.hasCompletedPartialWrappedEffect {
Task { @MainActor in
await source.replacementEffectDidComplete(self)
}
}
self.hasCompletedPartialWrappedEffect = true
}
#endif
func _add<Source>(to view: PlatformIntelligenceTextEffectView<Source>) async where Source : PlatformIntelligenceTextEffectViewSource, Source.Chunk == Chunk {
// The WT interfaces expect the replacement operation to be performed synchronously, else the source
// and destination effects become disjoint and begin at different times.
//
// To workaround this, the replacement is performed immediately (with the text hidden prior so that
// it is not visible to the user) before the actual effects begins. The source preview is generated
// prior to this, and the destination preview is generated after. This allows the previews to be cached
// so that they can later be retrieved by the WT interface delegates.
let sourcePreview = await view.source.textPreview(for: self.chunk)
await view.source.updateTextChunkVisibility(self.chunk, visible: false)
let (destinationPreview, remainderPreview) = await view.source.performReplacementAndGeneratePreview(for: self.chunk, effect: self)
guard let destinationPreview else {
assertionFailure("Failed to generate destination text preview for replacement effect")
return
}
#if canImport(UIKit)
let chunkAdapter = UIReplacementTextEffectTextChunkAdapter(wrapping: self.chunk, source: sourcePreview, destination: destinationPreview)
let delegateAdapter = UIReplacementTextEffectDelegateAdapter(wrapping: view.source, view: view)
let wrappedEffect = UITextEffectView.ReplacementTextEffect(chunk: chunkAdapter, view: view.wrapped, delegate: delegateAdapter)
await view.source.replacementEffectWillBegin(self)
view.wrapped.addEffect(wrappedEffect)
view.wrappedEffectIDToPlatformEffects[wrappedEffect.id] = self
view.platformEffectIDToWrappedEffectIDs[self.id] = wrappedEffect.id
#else
// The WTUI interface on macOS exposes the replacement effect as two separate effects, a source effect
// and a destination effect. To abstract this disparity between the platforms, the effects are modeled
// as a single replacement effect, to match the iOS interface and provide a cohesive API.
let sourceChunkAdapter = WTTextChunkAdapter(wrapping: self.chunk, preview: sourcePreview)
let destinationChunkAdapter = WTTextChunkAdapter(wrapping: self.chunk, preview: destinationPreview)
let wrappedDestinationEffect = _WTReplaceTextEffect(chunk: destinationChunkAdapter, effectView: view.wrapped)
wrappedDestinationEffect.isDestination = true
wrappedDestinationEffect.animateRemovalWhenDone = true
wrappedDestinationEffect.completion = {
// The destination completion handler is invoked right before it starts its opacity animation.
self.didCompletePartialWrappedEffect(for: view.source)
}
let wrappedSourceEffect = _WTReplaceTextEffect(chunk: sourceChunkAdapter, effectView: view.wrapped)
wrappedSourceEffect.animateRemovalWhenDone = false
wrappedSourceEffect.preCompletion = {
// This block is invoked after the source effect has been prepared, but before it actually begins.
// It's intended for the destination effect to be added here, synchronously.
let destinationEffectID = view.wrapped.add(wrappedDestinationEffect)!
view.wrappedEffectIDToPlatformEffects[destinationEffectID] = self
view.platformEffectIDToWrappedEffectIDs[self.id, default: []].insert(destinationEffectID)
}
wrappedSourceEffect.completion = {
// The source completion handler is invoked right after it ends its opacity animation.
self.didCompletePartialWrappedEffect(for: view.source)
}
await view.source.replacementEffectWillBegin(self)
let sourceEffectID = view.wrapped.add(wrappedSourceEffect)!
view.wrappedEffectIDToPlatformEffects[sourceEffectID] = self
view.platformEffectIDToWrappedEffectIDs[self.id, default: []].insert(sourceEffectID)
#endif
guard let remainderPreview else {
return
}
let previews = PlatformIntelligenceRemainderAffordanceTextEffect<Chunk>.Previews(source: sourcePreview, destination: destinationPreview, remainder: remainderPreview)
let remainderEffect = PlatformIntelligenceRemainderAffordanceTextEffect(chunk: chunk, previews: previews)
await remainderEffect._add(to: view)
}
}
/// An effect which adds a shimmer animation to some text, intended to indicate that some operation is pending.
class PlatformIntelligencePonderingTextEffect<Chunk>: PlatformIntelligenceTextEffect where Chunk: PlatformIntelligenceTextEffectChunk {
#if canImport(UIKit)
private typealias ChunkAdapter = UIPonderingTextEffectTextChunkAdapter
#else
private typealias ChunkAdapter = WTTextChunkAdapter
#endif
let id = PlatformIntelligenceTextEffectID()
let chunk: Chunk
init(chunk: Chunk) {
self.chunk = chunk
}
func _add<Source>(to view: PlatformIntelligenceTextEffectView<Source>) async where Source : PlatformIntelligenceTextEffectViewSource, Source.Chunk == Chunk {
guard let preview = await view.source.textPreview(for: self.chunk) else {
assertionFailure("Failed to generate text preview for pondering effect")
return
}
let chunkAdapter = ChunkAdapter(wrapping: self.chunk, preview: preview)
#if canImport(UIKit)
let wrappedEffect = UITextEffectView.PonderingEffect(chunk: chunkAdapter, view: view.wrapped)
view.wrapped.addEffect(wrappedEffect)
view.wrappedEffectIDToPlatformEffects[wrappedEffect.id] = self
view.platformEffectIDToWrappedEffectIDs[self.id] = wrappedEffect.id
#else
let wrappedEffect = _WTSweepTextEffect(chunk: chunkAdapter, effectView: view.wrapped)
view.wrapped.add(wrappedEffect)
view.wrappedEffectIDToPlatformEffects[wrappedEffect.identifier] = self
view.platformEffectIDToWrappedEffectIDs[self.id] = [wrappedEffect.identifier]
#endif
}
}
#endif
|