Reputation: 7467
I am working with Swift Data (iOS 17 or higher only) and SwiftUI on iOS.
This question is probably only worth reading (or deciding whether to vote to close!!!) if you are highly active with this framework.
I have a View (happens to be a "Recording Editor View/Widget") that currently is highly coupled with Swift Data in my code by using the @Bindable macro. This is very convenient and appropriate in my own project, but what if I wanted to "agnostify" this view into a Swift Package where it did not assume or impose any particular "Memo" or "Recording" type (as it would be absurd to include a top-level Data Model in a Swift Package for a view).
I think if you read this code (a simplified example with all the essential relationships shown) or attempt this yourself, you will see the dilemma.
Here is the current code with some relevant comments:
App:
import SwiftUI
import SwiftData
@main
struct RecPackage_Stack_QApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}.modelContainer(for: VoiceMemo.self)
}
}
Models:
@Model
final class VoiceMemo {
var name: String
@Relationship(deleteRule: .cascade)
var recording: Recording?
func replaceRecording(newRecording: Recording) {
recording = newRecording
}
func deleteRecording() {
recording = nil
}
init(name: String, recording: Recording? = nil) {
self.name = name
self.recording = recording
}
}
@Model
final class Recording {
var attribute01:Double
var attribute02:Double
init(attribute01: Double = 0, attribute02: Double = 1) {
self.attribute01 = attribute01
self.attribute02 = attribute02
}
}
ContentView:
import SwiftUI
import SwiftData
enum Navigation {
case main, edit
}
struct ContentView: View {
@State var showEditScreen: Bool = false
@Query private var memos: [VoiceMemo]
@Environment(\.modelContext) var modelContext
@State private var memoToEdit: VoiceMemo?
var body: some View {
if !showEditScreen {
VStack {
Spacer()
Button("ADD MEMO") {
let newMemo = VoiceMemo(name: "new Memo...")
modelContext.insert(newMemo)
memoToEdit = newMemo
showEditScreen = true
}
Spacer()
ForEach(memos, id: \.self) { memo in
HStack{
Button(memo.name) {
memoToEdit = memo
showEditScreen = true
}
if let rec = memo.recording {
Text(rec.attribute01.description)
Text(rec.attribute02.description)
}
}
}
Spacer()
}
.padding()
} else {
AddEditScreen(memo: memoToEdit!, showThisScreen: $showEditScreen)
}
}
}
AddEditScreen:
struct AddEditScreen: View {
@Bindable var memo:VoiceMemo
@Binding var showThisScreen:Bool
var body: some View {
VStack(alignment: .leading) {
Button("<< BACK to main") {
showThisScreen = false
}
Spacer()
TextField("hello", text: $memo.name)
Spacer()
RecordingEditorWidget(memo: memo)
}.padding(20)
}
}
RecordingEditorWidget:
// This is what I would like to extract/modularize/decouple/agnostify into a Swift Package
struct RecordingEditorWidget: View {
// Should have no knowledge of (or protocol for) this "Memo" object or the "Recording" object
@Bindable var memo:VoiceMemo
@State var isRecording: Bool = false
enum RecordingState {
case blank, recording, recorded
}
var getState: RecordingState {
if isRecording {
return .recording
} else if memo.recording == nil {
return .blank
} else {
return .recorded
}
}
var body: some View {
ZStack{
switch getState {
case .blank:
Button("Record") {
isRecording = true
// simulate recording
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
memo.recording = Recording()
isRecording = false
}
}
case .recording:
Text("Recording in progress...")
case .recorded:
// Simulated audio editor
@Bindable var recording = memo.recording!
VStack {
Spacer()
Slider(value: $recording.attribute01)
Spacer()
Slider(value: $recording.attribute02)
Spacer()
Button("Trash Recording") {
memo.deleteRecording()
}
}
}
}.background(.green.opacity(0.2))
}
}
How can this specific minimal example be made into an independent/agnostic Swift Package that only exposes appropriate bindings and/or callbacks and maintains its functionality?
Upvotes: 0
Views: 321
Reputation: 273310
This is where protocols are very useful. You should look at how your view uses VoiceMemo
and Recording
, and figure out what your protocol should require implementers to have.
From what I can see, you can declare :
// VoiceMemo will conform to this protocol
protocol RecordableMemo: Observable & AnyObject {
associatedtype Record: RecordedData
var recording: Record? { get set }
func deleteRecording()
}
// Recording will conform to this protocol
protocol RecordedData: Observable & AnyObject {
init()
var attribute01: Double { get set }
var attribute02: Double { get set }
}
(I tried to name them something different from the concrete implementations, but I think my names are quite bad.)
RecordedData
requires an empty initialiser here, but in your real code you are probably not doing memo.recording = Recording()
. You might require a init(data: Data)
or init(buffer: AVAudioBuffer)
or whatever.
Example implementations:
@Model
final class Recording: RecordedData {
init() { }
var attribute01 = 0.0
var attribute02 = 0.0
}
@Model
final class VoiceMemo: RecordableMemo {
func deleteRecording() {
recording = nil // as an example
}
var recording: Recording?
init(recording: Recording? = nil) {
self.recording = recording
}
}
Now you can add a generic type parameter to your view:
struct RecordingEditorWidget<Memo: RecordableMemo>: View {
@Bindable var memo: Memo
// instead of "Recording", use "Memo.Record"
memo.recording = Memo.Record()
Full code:
struct RecordingEditorWidget<Memo: RecordableMemo>: View {
@Bindable var memo: Memo
@State var isRecording: Bool = false
enum RecordingState {
case blank, recording, recorded
}
var getState: RecordingState {
if isRecording {
return .recording
} else if memo.recording == nil {
return .blank
} else {
return .recorded
}
}
var body: some View {
ZStack{
switch getState {
case .blank:
Button("Record") {
isRecording = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
memo.recording = Memo.Record()
isRecording = false
}
}
case .recording:
Text("Recording in progress...")
case .recorded:
// Simulated audio editor
@Bindable var recording = memo.recording!
VStack {
Spacer()
Slider(value: $recording.attribute01)
Spacer()
Slider(value: $recording.attribute02)
Spacer()
Button("Trash Recording") {
memo.deleteRecording()
}
}
}
}.background(.green.opacity(0.2))
}
}
Upvotes: 1