-
-
Notifications
You must be signed in to change notification settings - Fork 153
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
Comments
@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 😊 |
@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. |
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 |
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. |
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
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): 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. |
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. StylingSo for things like 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 ( 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))
}
}
BehaviourFor behaviour, that's where modifiers work best, In addition, I'd generally prefer an For an example, checkout my 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 InitialiserI generally try to keep my 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 For an example, checkout my You can see this throughout the SwiftUI APIs as well of course, long/complicated
|
Another consideration is how best to 'write' the Representable to make it easy to maintain.
BoilerplateI 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.
Using this approach you only need to manage properties on
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. |
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. |
@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. |
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 |
Sure, thank you so much. |
This is great! Any plans to get this merged? |
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)
}
} |
@ericlewis Thanks for sharing this! 🙏 |
Is your feature request related to a problem? Please describe.
The current
TextView
is built to be used withUIKit
.Describe the solution you'd like
Create a
SwiftUITextView
conforming toUIViewRepresentable
, to wrapTextView
for SwiftUI.Additional context
I will try to make a PR to solve the problem.
The text was updated successfully, but these errors were encountered: