Nerdy Bunz
Nerdy Bunz

Reputation: 7467

How to decouple Swift Data from a SwiftUI view in order to make the view available as a Swift Package?

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

Answers (1)

Sweeper
Sweeper

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

Related Questions