// The MIT License (MIT) // // Copyright (c) 2020–2022 Alexander Grebenyuk (github.com/kean). import SwiftUI import CoreData import Pulse import Combine #if os(macOS) || os(iOS) struct RichTextView: View { @ObservedObject private var viewModel: RichTextViewModel @State private var isExpanded = false @State private var isScrolled = false @State private var errorViewOpacity = 0.0 var isAutomaticLinkDetectionEnabled = true var hasVerticalScroller = false var onToggleExpanded: (() -> Void)? @ViewBuilder var extraMenu: () -> ExtraMenu init(viewModel: RichTextViewModel, isAutomaticLinkDetectionEnabled: Bool = true, hasVerticalScroller: Bool = true, onToggleExpanded: (() -> Void)? = nil, @ViewBuilder extraMenu: @escaping () -> ExtraMenu) { self.viewModel = viewModel self.isAutomaticLinkDetectionEnabled = isAutomaticLinkDetectionEnabled self.hasVerticalScroller = hasVerticalScroller self.onToggleExpanded = onToggleExpanded self.extraMenu = extraMenu } #if os(iOS) var body: some View { VStack(spacing: 0) { searchToolbar .backport.backgroundThickMaterial(enabled: isExpanded && isScrolled) ZStack(alignment: .bottom) { textView .edgesIgnoringSafeArea(.bottom) errorView } if viewModel.isSearching { SearchToobar(viewModel: viewModel) } } .onAppear(perform: onAppear) } private var searchToolbar: some View { HStack(spacing: 0) { SearchBar(title: "Search", text: $viewModel.searchTerm, onEditingChanged: { isEditing in if isEditing { viewModel.isSearching = isEditing } }) if #available(iOS 14.0, *) { Menu(content: { StringSearchOptionsMenu(options: $viewModel.options, isKindNeeded: false) let extraMenu = self.extraMenu() if !(extraMenu is EmptyView) { extraMenu } }, label: { Image(systemName: "ellipsis.circle") .font(.system(size: 20)) .frame(width: 40, height: 44) }) } if let onToggleExpanded = onToggleExpanded { Button(action: { isExpanded.toggle() onToggleExpanded() }) { Image(systemName: isExpanded ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") .font(.system(size: 20)) .frame(width: 40, height: 44) } } } .padding(EdgeInsets(top: -2, leading: 4, bottom: -2, trailing: 6)) .border(width: isScrolled ? 1 : 0, edges: [.bottom], color: Color(UXColor.separator).opacity(0.3)) } private var textView: some View { WrappedTextView(text: viewModel.text, viewModel: viewModel, isAutomaticLinkDetectionEnabled: isAutomaticLinkDetectionEnabled, isScrolled: $isScrolled) } #else var body: some View { VStack(spacing: 0) { #if os(macOS) ZStack(alignment: .bottom) { WrappedTextView(text: viewModel.text, viewModel: viewModel, isAutomaticLinkDetectionEnabled: isAutomaticLinkDetectionEnabled, hasVerticalScroller: hasVerticalScroller) errorView }.onAppear(perform: onAppear) #else WrappedTextView(text: viewModel.text, viewModel: viewModel, isAutomaticLinkDetectionEnabled: isAutomaticLinkDetectionEnabled) #endif #if !os(tvOS) Divider() SearchToobar(viewModel: viewModel) #endif } } #endif private func onAppear() { guard viewModel.error != nil else { return } viewModel.onAppear() withAnimation { errorViewOpacity = 1.0 } } @ViewBuilder private var errorView: some View { if let error = viewModel.error?.context?.debugDescription { HStack { Spacer() HStack { Image(systemName: "xmark.octagon.fill") .font(.title) Text(error).bold() } .foregroundColor(.white) .padding(8) .background(Color.red.opacity(0.8)) .cornerRadius(12) .frame(maxWidth: 400) Spacer() } .padding(.bottom, 16) .onTapGesture { withAnimation { errorViewOpacity = 0 } } .opacity(errorViewOpacity) } } } extension RichTextView where ExtraMenu == EmptyView { init(viewModel: RichTextViewModel) { self.init(viewModel: viewModel, extraMenu: { EmptyView() }) } } #if os(tvOS) || os(iOS) private struct WrappedTextView: UIViewRepresentable { let text: NSAttributedString let viewModel: RichTextViewModel let isAutomaticLinkDetectionEnabled: Bool #if os(iOS) @Binding var isScrolled: Bool #endif final class Coordinator { var cancellables: [AnyCancellable] = [] } func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> UXTextView { let textView = UITextView() configureTextView(textView) textView.autocorrectionType = .no textView.autocapitalizationType = .none #if !os(tvOS) textView.isAutomaticLinkDetectionEnabled = isAutomaticLinkDetectionEnabled #endif textView.textContainerInset = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10) textView.attributedText = text #if os(iOS) textView.publisher(for: \.contentOffset, options: [.new]) .map { $0.y >= 10 } .removeDuplicates() .receive(on: DispatchQueue.main) .sink { isScrolled in withAnimation { self.isScrolled = isScrolled } } .store(in: &context.coordinator.cancellables) #endif viewModel.textView = textView return textView } func updateUIView(_ uiView: UXTextView, context: Context) { // Do nothing } } #elseif os(macOS) private struct WrappedTextView: NSViewRepresentable { let text: NSAttributedString let viewModel: RichTextViewModel let isAutomaticLinkDetectionEnabled: Bool var hasVerticalScroller: Bool func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() scrollView.hasVerticalScroller = hasVerticalScroller let textView = scrollView.documentView as! NSTextView configureTextView(textView) textView.isAutomaticSpellingCorrectionEnabled = false textView.textContainerInset = NSSize(width: 10, height: 10) viewModel.textView = textView return scrollView } func updateNSView(_ nsView: NSScrollView, context: Context) { let textView = (nsView.documentView as! NSTextView) textView.attributedText = text viewModel.textView = textView } } #endif private func configureTextView(_ textView: UXTextView) { textView.isSelectable = true #if !os(tvOS) textView.isEditable = false textView.linkTextAttributes = [ .foregroundColor: JSONColors.valueString, .underlineStyle: 1 ] #endif textView.backgroundColor = .clear } #endif #if os(iOS) || os(macOS) private struct SearchToobar: View { @ObservedObject var viewModel: RichTextViewModel #if os(iOS) var body: some View { HStack { HStack(spacing: 12) { Button(action: viewModel.previousMatch) { Image(systemName: "chevron.left.circle") .font(.system(size: 20)) }.disabled(viewModel.matches.isEmpty) Text(viewModel.matches.isEmpty ? "0 of 0" : "\(viewModel.selectedMatchIndex+1) of \(viewModel.matches.count)") .font(Font.body.monospacedDigit()) Button(action: viewModel.nextMatch) { Image(systemName: "chevron.right.circle") .font(.system(size: 20)) }.disabled(viewModel.matches.isEmpty) } .fixedSize() Spacer() Button(action: viewModel.cancelSearch) { Text("Cancel") } } .padding(12) .border(width: 1, edges: [.top], color: Color(UXColor.separator).opacity(0.3)) .backport.backgroundThickMaterial() } #else var body: some View { HStack { SearchBar(title: "Search", text: $viewModel.searchTerm, onEditingChanged: { isEditing in if isEditing { viewModel.isSearching = isEditing } }, onReturn: viewModel.nextMatch).frame(maxWidth: 240) Menu(content: { StringSearchOptionsMenu(options: $viewModel.options, isKindNeeded: false) }, label: { Image(systemName: "ellipsis.circle") .font(.system(size: 20)) .frame(width: 40, height: 44) }) .menuStyle(.borderlessButton) .fixedSize() Spacer() HStack(spacing: 12) { Text(viewModel.matches.isEmpty ? "0/0" : "\(viewModel.selectedMatchIndex+1)/\(viewModel.matches.count)") .font(Font.body.monospacedDigit()) .foregroundColor(.secondary) Button(action: viewModel.previousMatch) { Image(systemName: "chevron.left") } .buttonStyle(.plain) .disabled(viewModel.matches.isEmpty) Button(action: viewModel.nextMatch) { Image(systemName: "chevron.right") } .buttonStyle(.plain) .disabled(viewModel.matches.isEmpty) } .fixedSize() } .padding(6) } #endif } final class RichTextViewModel: ObservableObject { @Published private(set) var selectedMatchIndex: Int = 0 @Published private(set) var matches: [Range] = [] @Published var isSearching = false @Published var searchTerm: String = "" @Published var options: StringSearchOptions = .default var error: NetworkLogger.DecodingError? let text: NSAttributedString private let string: String weak var textView: UXTextView? var mutableText: NSMutableAttributedString { textView?.textStorage ?? NSMutableAttributedString() } var isAutomaticLinkDetectionEnabled = true private var bag = [AnyCancellable]() convenience init(json: Any, error: NetworkLogger.DecodingError?) { let renderer = AttributedStringJSONRenderer(fontSize: FontSize.body, lineHeight: FontSize.body + 5) let printer = JSONPrinter(renderer: renderer) printer.render(json: json, error: error) self.init(string: renderer.make()) self.error = error } convenience init(string: String) { self.init(string: RichTextViewModel.makeAttributedString(for: string)) } static func makeAttributedString(for string: String) -> NSAttributedString { NSAttributedString(string: string, attributes: [.font: preferredFont(), .foregroundColor: UXColor.label]) } static func preferredFont() -> UXFont { #if os(macOS) NSFont.preferredFont(forTextStyle: .body, options: [:]) #else UXFont.systemFont(ofSize: FontSize.body + 3, weight: .regular) #endif } init(string: NSAttributedString) { self.text = string self.string = string.string Publishers.CombineLatest($searchTerm, $options).sink { [weak self] in self?.refresh(searchTerm: $0, options: $1) }.store(in: &bag) } func onAppear() { if error != nil { let range = NSRange(location: 0, length: text.length) text.enumerateAttribute(.decodingError, in: range) { _, range, _ in var range = range if range.location > 50 { range.location -= 50 } textView?.scrollRangeToVisible(range) } } } private func refresh(searchTerm: String, options: StringSearchOptions) { selectedMatchIndex = 0 clearMatches() guard !searchTerm.isEmpty, searchTerm.count > 1 else { return } let ranges = string.ranges(of: searchTerm, options: .init(options)) for range in ranges { highlight(range: range, in: mutableText) } selectedMatchIndex = 0 matches = ranges didUpdateCurrentSelectedMatch() } func cancelSearch() { searchTerm = "" isSearching = false hideKeyboard() } func nextMatch() { guard !matches.isEmpty else { return } updateMatchIndex(selectedMatchIndex + 1 < matches.count ? selectedMatchIndex + 1 : 0) } func previousMatch() { guard !matches.isEmpty else { return } updateMatchIndex(selectedMatchIndex - 1 < 0 ? matches.count - 1 : selectedMatchIndex - 1) } private func updateMatchIndex(_ newIndex: Int) { let previousIndex = selectedMatchIndex selectedMatchIndex = newIndex didUpdateCurrentSelectedMatch(previousMatch: previousIndex) } private func didUpdateCurrentSelectedMatch(previousMatch: Int? = nil) { guard !matches.isEmpty else { return } // Scroll to visible range var range = NSRange(matches[selectedMatchIndex], in: string) if range.length + 50 < string.count { range.length += 50 } if let textView = textView { textView.scrollRangeToVisible(range) } // Update highlights if let previousMatch = previousMatch { highlight(range: matches[previousMatch], in: mutableText) } highlight(range: matches[selectedMatchIndex], in: mutableText, isFocused: true) } private func clearMatches() { for match in matches { let range = NSRange(match, in: string) mutableText.removeAttribute(.foregroundColor, range: range) if let originalForegroundColor = text.attribute(.foregroundColor, at: range.lowerBound, effectiveRange: nil) { mutableText.addAttribute(.foregroundColor, value: originalForegroundColor, range: range) } mutableText.removeAttribute(.backgroundColor, range: range) } matches.removeAll() } } private func highlight(range: Range, in text: NSMutableAttributedString, isFocused: Bool = false) { let range = NSRange(range, in: text.string) text.addAttributes([ .backgroundColor: UXColor.systemBlue.withAlphaComponent(isFocused ? 0.8 : 0.3), .foregroundColor: UXColor.white ], range: range) } #if DEBUG struct RichTextView_Previews: PreviewProvider { static var previews: some View { RichTextView(viewModel: .init(json: try! JSONSerialization.jsonObject(with: MockJSON.allPossibleValues), error: nil)) .frame(height: 600) .previewLayout(.sizeThatFits) } } #endif #endif