Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
elijah-santos25 committed Aug 14, 2024
0 parents commit 388d368
Show file tree
Hide file tree
Showing 20 changed files with 942 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
20 changes: 20 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "AttachmentsEverywhere",
platforms: [.macOS(.v15), .iOS(.v18)],
products: [
.library(
name: "AttachmentsEverywhere",
targets: ["AttachmentsEverywhere"]),
],
targets: [
.target(
name: "AttachmentsEverywhere",
resources: [.process("Resources/GlassMaterial.usda")]
),
]
)
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# AttachmentsEverywhere
Backports `RealityView`'s `init(make:update:attachments)` to non-visionOS platforms.

## Basic Usage
- Use as a standard SPM dependency
- Import `AttachmentsEverywhere` in your code
- Replace any `RealityView` that uses `init(make:update:attachments:)` with `AttachableRealityView`
- Your code will probably (hopefully_ compile, though it may not function properly without tweaking

## Advanced Usage
In order to approach the usability of attachments on visionOS, `AttachmentsEverywhere` provides some
extensions on top of the `RealityKit` API. These extensions are not available on visionOS, and
therefore must be wrapped inside `#if !os(visionOS)`.

### Glass Background Customization
By default, `AttachmentsEverywhere` adds a glass background (rather, a crude facsimile of it) to
attachments. Use the `.glassBackgroundStyle(_:)` modifier on `Attachment` with a
`GlassBackgroundType` in order to customize this behavior:
- `.capsule`: The view's glass background will be a capsule
(corner radius equal to half the height of the attachment).
- `.roundedRectangle(cornerRadius:)`: The view's glass background will be a rounded rectangle,
with a corner radius defined in points.
- `.none`: The view will not have a glass background.

### Gestures
Use `.onTapGesture(_:)` on `Attachment` in order to respond to tap gestures. At this time, this is
the only supported gesture. Your closure will be passed a `CGPoint` which is the location of the tap
in your view's coordinate space.

### Attachment Scale
By default, attachment view entities are scaled such that 300 points is equivalent to one
(RealityView) meter. In order to customize this scaling, apply the
`.attachmentScale(pointsPerMeter:)` view modifier outside your `AttachableRealityView`.

## Important Considerations / Quirks
`AttachmentsEverywhere` relies on `SwiftUI`'s current behavior regarding `RealityView` view updates.
This behavior could change in future OS releases (i.e., past iOS 18.0 and macOS 15.0) and some
features of this library may cease functioning. This library is intended as a stopgap solution until
Apple (hopefully) adds attachments to non-visionOS platforms. As such, this library has some quirks
regarding its functionality that should be taken into account.

### Automatic Re-Rendering of Attachment Views
`Attachment`s will be re-rendered when `ImageRenderer` determines their state to have changed.
Crucially, this only seems to take into account `State`s, `Binding`s, etc. held *inside* the
attachment views. State held outside of `AttachableRealityView` *will not* automatically re-render
attachment views. Such state *must* be passed into `Attachment`s with `Binding` (and similar).

### Automatic Insertion / Deletion / Modification of Attachments
Every time `RealityView` would call the `update` closure, `AttachmentsEverywhere` reevaluates the
`attachments` closure. This means that `Attachment`s can be dynamically added or removed (with
result builder control flow) in response to state changes, though your surrounding view must depend
on these state variables. Just like with `RealityView`, any attachments must be added to the scene
manually. Any attachments that are removed due to a state change will be automatically removed from
the scene.

**At this time (until I find a clean way to make it function), `ForEach` is not supported
in the `attachments` closure.** Instead, use a for-in loop, as it is supported.

## Library To-Do
- [ ] Support `ForEach` (if possible) in order to bolster code compatibility
- [ ] Support other gestures
- [ ] Create better glass material
172 changes: 172 additions & 0 deletions Sources/AttachmentsEverywhere/API/AttachableRealityView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//
// AttachableRealityView.swift
// AttachmentsEverywhere
//
// Created by Elijah Santos on 8/7/24.
//

#if !os(visionOS)

import SwiftUI
import RealityKit

/// Replicates (to the extent possible) the attachments functionality of `RealityView` on visionOS.
///
/// Use as you would a `RealityView`. On visionOS, `AttachableRealityView` is a typealias for `RealityView`.
@MainActor
public struct AttachableRealityView<Placeholder: View>: View {
private enum AttachmentUpdateState {
case notInProgress, preUpdate, postUpdate
}
private let make: @MainActor (inout RealityViewCameraContent, RealityViewAttachments) async -> Void
private let update: (@MainActor (inout RealityViewCameraContent, RealityViewAttachments) -> Void)?
private let placeholder: Placeholder?
private let attachments: () -> AttachmentContent

@Environment(\.attachmentPointsPerMeter) private var pointsPerMeter
@StateObject private var updator = Updator()
@State private var n = 0
@State private var attachmentUpdateState = AttachmentUpdateState.notInProgress
@State private var cachedAttachments: RealityViewAttachmentsBacking? = nil

public init(
make: @escaping @MainActor (inout RealityViewCameraContent, RealityViewAttachments) async -> Void,
update: (@MainActor (inout RealityViewCameraContent, RealityViewAttachments) -> Void)? = nil,
@AttachmentContentBuilder attachments: @escaping () -> AttachmentContent
) where Placeholder == Never {
self.make = make
self.update = update
self.placeholder = nil
self.attachments = attachments
}

public init(
make: @escaping @MainActor (inout RealityViewCameraContent, RealityViewAttachments) async -> Void,
update: (@MainActor (inout RealityViewCameraContent, RealityViewAttachments) -> Void)? = nil,
@ViewBuilder placeholder: () -> Placeholder,
@AttachmentContentBuilder attachments: @escaping () -> AttachmentContent
) {
self.make = make
self.update = update
self.placeholder = placeholder()
self.attachments = attachments
}

// TODO: reimplement non-attachment initializers, for convenience

private func createAttachments() async {
self.cachedAttachments = await RealityViewAttachmentsBacking(
metersPerRenderedPx: 1 / (pointsPerMeter * ImageGenerator_renderScale),
views: attachments()) {
Task { @MainActor in
do {
var attachments = cachedAttachments
try await attachments?.regenerateAttachments()
self.cachedAttachments = attachments
self.updator.forceViewUpdate()
} catch {
print("[AttachmentsEverywhere] warning: failed to update attachments: \(error.localizedDescription)")
}
}
}
}

private func updateNewAndRemovedAttachments(
new: AttachmentContent,
cached: RealityViewAttachmentsBacking,
content: inout RealityViewCameraContent
) -> RealityViewAttachments? {
if attachmentUpdateState == .postUpdate {
// attachments now loaded; continue update with new attachments
DispatchQueue.main.async {
attachmentUpdateState = .notInProgress
}
return cached.collection()
}
let newPairs = new.filter { !cached.keys.contains($0.key) }
let removedKeys = cached.keys.filter { !new.keys.contains($0) }
if !removedKeys.isEmpty {
self.cachedAttachments?.removeAttachments(forKeys: removedKeys, from: &content)
}

if !newPairs.isEmpty {
Task { @MainActor [newPairs] in
attachmentUpdateState = .preUpdate
var c = self.cachedAttachments
await c?.insertAttachments(newPairs)
self.cachedAttachments = c
updator.forceViewUpdate()
attachmentUpdateState = .postUpdate
}
// defer update until attachments loaded
return nil
}
// no attachments to load; proceed with update
return cached.collection(excluding: removedKeys)
}

private func doUpdate(_ content: inout RealityViewCameraContent) {
_ = updator.updateCount
guard let cachedAttachments else { return }
let newAttachments = attachments()
if let pushedAttachments = updateNewAndRemovedAttachments(
new: newAttachments,
cached: cachedAttachments,
content: &content
) {
update?(&content, pushedAttachments)
}
}

public var body: some View {
Group {
if let placeholder {
RealityView { content in
_ = updator.updateCount
await createAttachments()
await make(&content, cachedAttachments!.collection())
} update: { content in
doUpdate(&content)
} placeholder: {
placeholder
}
} else {
RealityView { content in
_ = updator.updateCount
await createAttachments()
await make(&content, cachedAttachments!.collection())
} update: { content in
doUpdate(&content)
}

}
}
.gesture(
SpatialTapGesture()
.targetedToEntity(where: .has(ViewAttachmentComponent.self))
.onEnded { value in
guard let entity = value.entity as? ViewAttachmentEntity,
let tapPoint3D = value.hitTest(
point: value.location,
in: .local).first?.position
else {
return
}
let attachmentOrientation = entity.orientation(relativeTo: nil)
let attachmentPosition = entity.position(relativeTo: nil)
let xyAlignedPoint = simd_act(simd_negate(attachmentOrientation), tapPoint3D - attachmentPosition)
let resultantPoint = CGPoint(
x: Double(((xyAlignedPoint.x + (entity.attachment.bounds.extents.x / 2)) * pointsPerMeter)) - Attachment_padding,
y: Double((entity.attachment.bounds.extents.y - xyAlignedPoint.y - (entity.attachment.bounds.extents.y / 2)) * pointsPerMeter) - Attachment_padding)
entity.attachment.tapResponder?(resultantPoint)
}
)
.overlay {
// force swiftui to do a view update when updator changes
Text(updator.updateCount.description)
.hidden()
}
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// AttachableRealityView_visionOS.swift
// AttachmentsEverywhere
//
// Created by Elijah Santos on 8/7/24.
//

#if os(visionOS)
import SwiftUI
import RealityKit

// since visionOS has attachments, no custom implementation is needed; only a typealias
public typealias AttachableRealityView = RealityView
#endif
8 changes: 8 additions & 0 deletions Sources/AttachmentsEverywhere/API/Attachments/.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// File.swift
// AttachmentsEverywhere
//
// Created by Elijah Santos on 8/11/24.
//

import Foundation
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// Attachment+modifiers.swift
// AttachmentsEverywhere
//
// Created by Elijah Santos on 8/11/24.
//

import Foundation
import SwiftUI

#if !os(visionOS)

extension Attachment {
internal func withConfigurationValue<T>(
_ kp: WritableKeyPath<AttachmentCustomization, T>,
setTo newValue: T
) -> Attachment {
var copy = self
copy.configuration[keyPath: kp] = newValue
return copy
}

public func glassBackgroundStyle(_ style: GlassBackgroundType) -> Self {
self.withConfigurationValue(\.backgroundType, setTo: style)
}

public func onTapGesture(
_ action: @MainActor @escaping (_ location: CGPoint) -> Void
) -> Self {
let new: @MainActor (CGPoint) -> Void
if let existing = configuration.tapResponder {
new = {
existing($0)
action($0)
}
} else {
new = action
}
return self.withConfigurationValue(\.tapResponder, setTo: new)
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// AttachmentCustomization.swift
// AttachmentsEverywhere
//
// Created by Elijah Santos on 8/11/24.
//

import Foundation
import CoreGraphics

public struct AttachmentCustomization: Sendable {
var backgroundType: GlassBackgroundType
var tapResponder: (@MainActor (CGPoint) -> Void)?

static let `default`: AttachmentCustomization = .init(
backgroundType: .roundedRectangle(cornerRadius: 20),
tapResponder: nil)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// GlassBackgroundType.swift
// AttachmentsEverywhere
//
// Created by Elijah Santos on 8/11/24.
//

import Foundation

/// Describes the type of glass background placed behind an attachment.
///
/// - SeeAlso: `Attachment.glassBackgroundStyle(_:)`
public enum GlassBackgroundType: Hashable, Sendable {
/// A capsule (flat top and bottom edges, circular left and right).
case capsule

/// A rounded rectangle.
///
/// `cornerRadius` is defined in points.
case roundedRectangle(cornerRadius: Double)

/// No glass background.
case none
}
Loading

0 comments on commit 388d368

Please sign in to comment.