Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

View for SwiftUI using UIViewRepresentable #13

Open
nathanfallet opened this issue May 5, 2022 · 14 comments
Open

View for SwiftUI using UIViewRepresentable #13

nathanfallet opened this issue May 5, 2022 · 14 comments
Labels
enhancement New feature or request

Comments

@nathanfallet
Copy link

Is your feature request related to a problem? Please describe.
The current TextView is built to be used with UIKit.

Describe the solution you'd like
Create a SwiftUITextView conforming to UIViewRepresentable, to wrap TextView for SwiftUI.

Additional context
I will try to make a PR to solve the problem.

@nathanfallet nathanfallet added the enhancement New feature or request label May 5, 2022
@simonbs
Copy link
Owner

simonbs commented May 6, 2022

@nathanfallet I'll happily merge a PR with support for SwiftUI! What do you think of putting the SwiftUI specific code in a separate library? Like RunestoneSwiftUI. It should live in the same package, of course.

In the long run we could consider splitting out the UIKit specific code to a separate library as well, so people choose which UI framework they prefer. That would also allow for something like RunestoneAppKit.

It's venting an idea. I'm not sure what the common way of handling this is 😊

@nathanfallet
Copy link
Author

@simonbs Seems to be a great idea. For SwiftUI, the view can be created from TextView with UIViewRepresentable. For AppKit, a part of the work has to be done again, since UIKit and AppKit are not linked directly.

@nighthawk
Copy link
Contributor

I'm working on this in a fork of mine. Any preferences for what the API should look like?

So far I have this:

RunestoneSwiftUI.TextEditor(
  text: $text,
  theme: TomorrowTheme.shared,
  language: model.language,
  configuration: .init(isEditable: false, showLineNumbers: false)
)
.themeFontSize(14)
.frame(minHeight: 100, maxHeight: 300)

Works well so far, but I wonder how best to model the different options for Runestone.TextView in a way that feels right in SwiftUI. I'm playing around with two approaches: a static configuration that you pass into the constructor as a struct, and view modifiers. Adding view modifiers feel more at home in SwiftUI but there are tons of options so it might be excessive. Say the themeFontSize view modifier lets you override the font size of the main theme, which turned out useful in how I'm using it.

@simonbs
Copy link
Owner

simonbs commented Jun 3, 2022

I'm working on this in a fork of mine. Any preferences for what the API should look like?

So far I have this:

RunestoneSwiftUI.TextEditor(
  text: $text,
  theme: TomorrowTheme.shared,
  language: model.language,
  configuration: .init(isEditable: false, showLineNumbers: false)
)
.themeFontSize(14)
.frame(minHeight: 100, maxHeight: 300)

Works well so far, but I wonder how best to model the different options for Runestone.TextView in a way that feels right in SwiftUI. I'm playing around with two approaches: a static configuration that you pass into the constructor as a struct, and view modifiers. Adding view modifiers feel more at home in SwiftUI but there are tons of options so it might be excessive. Say the themeFontSize view modifier lets you override the font size of the main theme, which turned out useful in how I'm using it.

Sounds great! I'm also unsure what the right approach is here. I'm leaning towards view modifiers since that seems more SwiftUI-like but I agree that we'll have to add many. However, the initialiser for the constructor will also become quite overwhelming. I'm aware we can add default values to it but it'll look overwhelming in the auto-completion dialog.

Do you think one or the other is better in terms of documentation? I'd love to have the SwiftUI interface documented in Runestone's DocC documentation.

@nighthawk
Copy link
Contributor

Sounds great! I'm also unsure what the right approach is here. I'm leaning towards view modifiers since that seems more SwiftUI-like but I agree that we'll have to add many. However, the initialiser for the constructor will also become quite overwhelming. I'm aware we can add default values to it but it'll look overwhelming in the auto-completion dialog.

Good point, I agree that view modifiers are more SwiftUI-like. One downside is that they are all added a the global scope for any View (at least as far as I'm away) and could lead to a bit of autocompletion "pollution" for all other SwiftUI views when importing the library. Maybe they could all follow a .runestone* naming, so that they are at least grouped together and easy to find through the autocompletion list.

Do you think one or the other is better in terms of documentation? I'd love to have the SwiftUI interface documented in Runestone's DocC documentation.

In terms of documentation a struct with all the configuration options seems better as of Xcode 13. You get a dedicated page with just the properties and their documentation. Out-of-the-box new view modifiers don't get any special treatment in DocC and just appear in the long list of Default Implementations of the view's View Implementations (find mine in the list):

Screen Shot 2022-06-03 at 16 12 31

However, maybe Xcode 14 will improve that next week. Or the solution would be to have a manually maintained page in the documentation that points them out.

The in-line documentation of a view modifier as part of the autocompletion works well though and Xcode also picks it up automatically in the SwiftUI inspector, which is very nice.

@shaps80
Copy link

shaps80 commented Jul 20, 2022

Just wanted to chime in with my experience/opinion on this. I've been working with SwiftUI almost exclusively since day one and built quite a decent library of backports. I've also built 3 complete apps with SwiftUI over the past 2 years, including 1 very large cross--platform app.

Through this process, I feel I've gotten a pretty decent grasp of how best to augment UIKit view's, so FWIW here are my thoughts.

Generally its best to separate init, styling and behaviour.

Styling

So for things like theme, font, fontSize, etc. a great approach is to provide a styling API TextEditorStyle.

They're more composable (can be used on multiple editors), easy to define sensible defaults, yet still fully customisable by a user and they can either use an existing style with some tweaks, or start fresh.

Another consideration you'll have is that at least some of your styling APIs will require UIKit types (UIFont, UIColor) for example and there's no easy way to convert from SwiftUI in all cases (UIFont for example). So you'll have to think about how best to achieve this via a style, but it's totally possible.

For example:

struct DefaultEditorStyle: Runstone.TextEditorStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.editor // would return a specific type with special modifier's on it
            .font(.preferredFontForTextStyle(.body))
            .textColor(.label)
    }
}

extension View {
    func textEditorStyle<S: RunStone.TextEditorStyle(_ style: S) -> some View {
        environment(\.textEditorStyle, AnyTextEditorStyle(style))
    }
}

For a more complete example, checkout my ProgressView backport.

Behaviour

For behaviour, that's where modifiers work best, isEditable, showLineNumbers would be obvious candidates here.

In addition, I'd generally prefer an internal EnvironmentKey to avoid the pollution mentioned above (at least from a public interface POV).

For an example, checkout my NavigationDestination backports.

Using this approach, the user can still add their modifier's anywhere in the hierarchy, then your implementation can just look for them in the Environment. This also make overrides easier and more predicable for an API consumer.

Initialiser

I generally try to keep my init's pretty lightweight, focused generally on bindings like text however since the Editor really needs to know the language I'd recommend putting that in the init because I don't feel a modifier or style are an appropriate fit.

The reason for keeping them simple is because often times SwiftUI uses type-inference, for example to switch on/off behaviour. A list for example has a selection binding that can be either a single optional element, or an optional array of elements. This tells the list whether or not multiple selection should be enabled.

For an example, checkout my LabeledContent backport.

You can see this throughout the SwiftUI APIs as well of course, long/complicated init's make code management quite difficult and its not a great API consumer experience either IMO.

As your component scales, you may want to lean on this kind of API. So if you keep the init's minimal, you'll thank me later 🤣

@shaps80
Copy link

shaps80 commented Jul 20, 2022

Another consideration is how best to 'write' the Representable to make it easy to maintain.

Quick note on the name. SwiftUI.TextEditor exists and would make this annoying for a user, so I'd consider a more specific name, e.g. SyntaxEditor or wateva, something that needs more thought of course (naming is hard 😔)
For all code below I'll assume the name SyntaxEditor to distinguish between your SwiftUI and UIKit view's.

Boilerplate

I generally write some very basic boiler plate for all representable, I kinda worked this out through trial and error and gaining an understanding of the SwiftUI lifecycle, while keeping maintainability high.

  1. Define a view, not as a representable, this allows me to add standard modifiers as a part of the implementation if I need to. A common one is ignoresSafeArea since larger ScrollView based view's for example often require this to avoid clipping on notched devices. The API consumer could do this, but it comes down to user expectations.
  2. Add any custom modifiers (isEditable)
  3. Next I create 2 private extension's for Representable and Coordinator. This approach keeps them hidden and also enables me to use the same name throughout all my representable's, since they're name-spaced.
  4. The representable is really just a middle layer, so I keep it very simple. It has a single property (the original SyntaxEditor view) and you'll note that its make and update functions don't expose the backing view. Rather, they reach down through the Context.
  5. The Coordinator is generally used when you need an NSObject, delegate callbacks, etc. but I found its worth creating one for every representable since this creates a consistent and easy boilerplate that scales well. It also holds onto the original SyntaxEditor view for quick access, as well as the UIKit view its representing. Note, it has its own update function which is how we keep the view in-sync across SwiftUI lifecycle updates.

Using this approach you only need to manage properties on SyntaxView, and then map or interpret them as required in the Coordinator. More typically you'll see an approach where individual properties are propagated up/down the chain, but particularly for larger more complex representable's, I found this difficult to maintain, so I came up with this. I've not had any issues and I've been using this approach since iOS 13 for many view and even UIViewControllerRepresentable's

struct SyntaxEditor {
    @Environment(\.self) private var environment

    @Binding var text: String
    private var isEditable: Bool = true

    var body: some View {
        Representable(view: self)
    }
}

extension SyntaxEditor {
    func disabled(_ disable: Bool) -> Self {
        var view = self
        view.isEditable = !disable
        return view
    }
}

private extension SyntaxEditor {
    struct Representable: UIViewRepresentable {
        let view: SyntaxEditor

        func makeCoordinator() -> Coordinator {
            .init(view: view)
        }

        func makeUIView(context: Context) -> UIView {
            context.coordinator.uiView
        }

        func updateUIView(_ uiView: UIView, context: Context) {
            context.coordinator.update(view: view)
        }
    }
}

private extension SyntaxEditor {
    final class Coordinator {
        private var view: SyntaxEditor
        let uiView: TextEditor

        init(view: SyntaxEditor) {
            self.view = view
            uiView = .init()
        }

        func update(view: SyntaxEditor) {
            self.view = view
            uiView.isEditable = view.isEditable
            uiView.lineSpacing = view.environment.lineSpacing

            /*
             So as you need to add more properties, you can now just add them to
             `SyntaxView` and 'reach out' to them from here.

             As you can see you can also reach out to `EnvironmentValues` using this
             approach, which can be great for supporting common values a user would
             expect from SwiftUI APIs. In these cases you don't even need to provide
             custom support, just respect the current API.
             */
        }
    }
}

// Provided for demonstration purposes only
final class TextEditor: UIView {
    var text: String
    var isEditable: Bool = true
    var lineSpacing: CGFloat = 0

    init() {
        text = ""
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Note this is a fairly naive and trivial implementation. Meaning, the update func will be triggered for any state changes, meaning you'd end up potentially doing expensive work even as the user types. You would likely want to performance test and ensure this behaves as expected. Perhaps you're doing this work already in the UIKit layer, but be mindful of that.

I hope some of this is helpful in getting this into SwiftUI, cause its a fantastic Open-source project and a great implementation. I'd be keen to help where I can, certainly in the review process if you like.

@simonbs
Copy link
Owner

simonbs commented Jul 30, 2022

Thanks for your input @nighthawk and @shaps80. At the time of writing this, I'm not actively looking into making a SwiftUI wrapper around Runestone but I encourage anyone with the need and the time on their hands to look into it and I'm happy to ping-pong ideas.

@just-eric-99
Copy link

I'm working on this in a fork of mine. Any preferences for what the API should look like?

So far I have this:

RunestoneSwiftUI.TextEditor(
  text: $text,
  theme: TomorrowTheme.shared,
  language: model.language,
  configuration: .init(isEditable: false, showLineNumbers: false)
)
.themeFontSize(14)
.frame(minHeight: 100, maxHeight: 300)

Works well so far, but I wonder how best to model the different options for Runestone.TextView in a way that feels right in SwiftUI. I'm playing around with two approaches: a static configuration that you pass into the constructor as a struct, and view modifiers. Adding view modifiers feel more at home in SwiftUI but there are tons of options so it might be excessive. Say the themeFontSize view modifier lets you override the font size of the main theme, which turned out useful in how I'm using it.

@nighthawk Hello, I have only been learning SwiftUI for a few months and building an ios code playground based on SwiftUI. I tried implementing it on my own (uiviewrepresentable to wrap textview). however, I am not familiar with UiKit, as a result, it was a big mess. After a few months of finding other alternatives such as codemirror, monaco editor, etc, I realised that it is not suitable for my needs. That leads me back here ^_^! . Is is possible to show your solution for SwiftUI implementation? I would like to study on this. Thanks in advance.

@nighthawk
Copy link
Contributor

Is is possible to show your solution for SwiftUI implementation? I would like to study on this. Thanks in advance.

Sure. My implementation is in this fork, but it's not up-to-date with Simon's latest work: https://github.com/maparoni/Runestone/tree/swiftui

@just-eric-99
Copy link

Sure, thank you so much.

@jbromberg
Copy link

This is great! Any plans to get this merged?

@ericlewis
Copy link

ericlewis commented Jun 27, 2024

I haven't really seen one cohesive and working example, so here is one below:

import Runestone
import RunestoneThemeCommon
import RunestoneOneDarkTheme
import SwiftUI
import TreeSitterJavaScriptRunestone

enum EditorSettingKey {
    static let text = "Editor.text"
    static let showLineNumbers = "Editor.showLineNumbers"
    static let showInvisibleCharacters = "Editor.showInvisibleCharacters"
    static let wrapLines = "Editor.wrapLines"
    static let highlightSelectedLine = "Editor.highlightSelectedLine"
    static let showPageGuide = "Editor.showPageGuide"
    static let theme = "Editor.theme"
}

final class BasicCharacterPair: CharacterPair {
    let leading: String
    let trailing: String

    init(leading: String, trailing: String) {
        self.leading = leading
        self.trailing = trailing
    }
}

enum ThemeSetting: String, CaseIterable, Hashable {
    case oneDark
    
    var title: String {
        switch self {
        case .oneDark:
            return "One Dark"
        }
    }

    func makeTheme() -> EditorTheme {
        switch self {
        case .oneDark:
            return OneDarkTheme()
        }
    }
}

class SettingsContainer: ObservableObject {
    @AppStorage(EditorSettingKey.theme)
    var theme: ThemeSetting = .oneDark
    
    @AppStorage(EditorSettingKey.showLineNumbers)
    var showLineNumbers: Bool = false
    
    @AppStorage(EditorSettingKey.showInvisibleCharacters)
    var showInvisibleCharacters: Bool = false
    
    @AppStorage(EditorSettingKey.wrapLines)
    var wrapLines: Bool = true
    
    @AppStorage(EditorSettingKey.highlightSelectedLine)
    var highlightSelectedLine: Bool = true
    
    @AppStorage(EditorSettingKey.showPageGuide)
    var showPageGuide: Bool = false
}

@MainActor 
@Observable
class TextEditorViewModel {
    var textView: TextView?
    var delegate: Delegate?
    var text: String = ""

    func makeView() -> TextView {
        let textView = TextView()
        
        textView.alwaysBounceVertical = true
        textView.contentInsetAdjustmentBehavior = .always
        textView.autocorrectionType = .no
        textView.autocapitalizationType = .none
        textView.smartDashesType = .no
        textView.smartQuotesType = .no
        textView.smartInsertDeleteType = .no
        #if os(iOS)
        textView.textContainerInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
        #else
        textView.textContainerInset = UIEdgeInsets(top: 15, left: 5, bottom: 15, right: 5)
        #endif
        textView.lineSelectionDisplayType = .line
        textView.lineHeightMultiplier = 1.1
        textView.kern = 0.3
#if !os(visionOS)
        
        let config = UIHostingConfiguration {
            KeyboardToolsView(viewModel: self)
        }
        let v = config.makeContentView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = .secondarySystemBackground
        textView.inputAccessoryView = v
#endif
        textView.characterPairs = [
            BasicCharacterPair(leading: "(", trailing: ")"),
            BasicCharacterPair(leading: "{", trailing: "}"),
            BasicCharacterPair(leading: "[", trailing: "]"),
            BasicCharacterPair(leading: "\"", trailing: "\""),
            BasicCharacterPair(leading: "'", trailing: "'")
        ]
        textView.pageGuideColumn = 80
        
        let delegate = Delegate()
        delegate.parent = self
        textView.editorDelegate = delegate
        
        let state = TextViewState(text: text, language: .javaScript)
        textView.setState(state)
 
        self.delegate = delegate
        self.textView = textView
        return textView
    }

    @MainActor
    class Delegate: @preconcurrency TextViewDelegate {
        
        var parent: TextEditorViewModel?
        
        func textViewDidChange(_ textView: TextView) {
            parent?.text = textView.text
        }
    }
}

struct TextEditorView: UIViewRepresentable {
    
    @EnvironmentObject
    private var settings: SettingsContainer

    @Environment(TextEditorViewModel.self)
    private var model
    
    func makeUIView(context: Context) -> TextView {
        model.makeView()
    }
    
    func updateUIView(_ uiView: TextView, context: Context) {
        uiView.showLineNumbers = settings.showLineNumbers
        uiView.showTabs = settings.showInvisibleCharacters
        uiView.showSpaces = settings.showInvisibleCharacters
        uiView.showLineBreaks = settings.showInvisibleCharacters
        uiView.isLineWrappingEnabled = settings.wrapLines
        uiView.lineSelectionDisplayType = settings.highlightSelectedLine ? .line : .disabled
        uiView.showPageGuide = settings.showPageGuide
        
        let theme = settings.theme.makeTheme()
        uiView.theme = theme
        uiView.backgroundColor = theme.backgroundColor
        uiView.insertionPointColor = theme.textColor
        uiView.selectionBarColor = theme.textColor
        uiView.selectionHighlightColor = theme.textColor.withAlphaComponent(0.2)
        
        if model.text != uiView.text {
            uiView.text = model.text
            uiView.selectedRange = .init(location: uiView.text.count, length: 0)
        }
    }
}

struct KeyboardToolsView: View {
    
    var viewModel: TextEditorViewModel
    
    @State
    private var canUndo = false
    
    @State
    private var canRedo = false
    
    var body: some View {
        HStack(spacing: 20) {
            Button("Shift Left", systemImage: "arrow.left.to.line") {
                viewModel.textView?.shiftLeft()
            }
            Button("Shift Right", systemImage: "arrow.right.to.line") {
                viewModel.textView?.shiftRight()
            }
            Spacer()
            Button("Paste from Clipboard", systemImage: "document.on.clipboard") {
                if let text = UIPasteboard.general.string {
                    viewModel.text += text
                }
            }
            .disabled(!UIPasteboard.general.hasStrings)
            if let undoManager = viewModel.textView?.undoManager {
                Button("Undo", systemImage: "arrow.uturn.backward") {
                    undoManager.undo()
                }
                .disabled(!undoManager.canUndo)
                Button("Redo", systemImage: "arrow.uturn.forward") {
                    undoManager.redo()
                }
                .disabled(!undoManager.canRedo)
            }
            Button("Dismiss Keyboard", systemImage: "keyboard.chevron.compact.down") {
                viewModel.textView?.resignFirstResponder()
            }
        }
        .labelStyle(.iconOnly)
        .onChange(of: viewModel.text, initial: true) {
            canUndo = viewModel.textView?.undoManager?.canUndo ?? false
            canRedo = viewModel.textView?.undoManager?.canRedo ?? false
        }
        .padding(.bottom, 10)
    }
    
}

struct SettingsView: View {
    
    @Environment(\.dismiss)
    private var dismiss
    
    @EnvironmentObject
    private var settings: SettingsContainer
    
    var viewModel: TextEditorViewModel

    var body: some View {
        NavigationView {
            Form {
                Section(header: Text("Display")) {
                    Toggle("Show Line Numbers", isOn: $settings.showLineNumbers)
                    Toggle("Show Invisible Characters", isOn: $settings.showInvisibleCharacters)
                    Toggle("Wrap Lines", isOn: $settings.wrapLines)
                    Toggle("Highlight Selected Line", isOn: $settings.highlightSelectedLine)
                    Toggle("Show Page Guide", isOn: $settings.showPageGuide)
                }
                
                Section(header: Text("Theme")) {
                    Picker("Theme", selection: $settings.theme) {
                        ForEach(ThemeSetting.allCases, id: \.self) { theme in
                            Text(theme.title).tag(theme)
                        }
                    }
                }
            }
            .navigationTitle("Editor Settings")
            .toolbarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close") {
                        dismiss()
                    }
                }
            }
        }
    }
}

struct CodeEditorView: View {
    
    @State
    private var viewModel = TextEditorViewModel()
    
    @StateObject
    private var settings = SettingsContainer()
    
    @State
    private var isSettingsPresented = false
    
    @Binding
    var text: String
    
    var body: some View {
        TextEditorView()
            .environment(viewModel)
            .ignoresSafeArea(.container, edges: .bottom)
            .navigationTitle("Edit Code")
            .toolbarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarTitleMenu {
                    Button("Editor Settings", systemImage: "slider.horizontal.3") {
                        self.isSettingsPresented = true
                    }
                }
            }
            .sheet(isPresented: $isSettingsPresented) {
                SettingsView(viewModel: viewModel)
                    .presentationDetents([.medium, .large])
                    .presentationSizing(.form)
            }
            .onChange(of: viewModel.text + text) {
                text = viewModel.text
                viewModel.text = text
            }
            .environmentObject(settings)
    }
}

#Preview {
    @Previewable @State var text = ""
    NavigationStack {
        CodeEditorView(text: $text)
    }
}

@simonbs
Copy link
Owner

simonbs commented Jun 28, 2024

@ericlewis Thanks for sharing this! 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

7 participants