From 8281bdb3d3aec5aac58c2a4918724ca60bc5020c Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Thu, 7 Jan 2021 17:57:55 +0100 Subject: [PATCH] Refactor layout and fix macOS resize issue --- Sources/AttributedText/AttributedText.swift | 14 ++- .../AttributedText/AttributedTextStore.swift | 27 ----- .../AttributedTextView+AppKit.swift | 104 ------------------ .../AttributedTextView+UIKit.swift | 101 ----------------- .../AttributedTextViewWrapper+AppKit.swift | 27 ----- .../AttributedTextViewWrapper+UIKit.swift | 27 ----- .../NSLineBreakMode+TruncationMode.swift | 2 +- Sources/AttributedText/TextViewStore.swift | 25 +++++ .../TextViewWrapper+NSTextView.swift | 93 ++++++++++++++++ .../TextViewWrapper+UITextView.swift | 85 ++++++++++++++ .../AttributedTextTests.swift | 2 +- 11 files changed, 214 insertions(+), 293 deletions(-) delete mode 100644 Sources/AttributedText/AttributedTextStore.swift delete mode 100644 Sources/AttributedText/AttributedTextView+AppKit.swift delete mode 100644 Sources/AttributedText/AttributedTextView+UIKit.swift delete mode 100644 Sources/AttributedText/AttributedTextViewWrapper+AppKit.swift delete mode 100644 Sources/AttributedText/AttributedTextViewWrapper+UIKit.swift create mode 100644 Sources/AttributedText/TextViewStore.swift create mode 100644 Sources/AttributedText/TextViewWrapper+NSTextView.swift create mode 100644 Sources/AttributedText/TextViewWrapper+UITextView.swift diff --git a/Sources/AttributedText/AttributedText.swift b/Sources/AttributedText/AttributedText.swift index 8accd98..f022ec0 100644 --- a/Sources/AttributedText/AttributedText.swift +++ b/Sources/AttributedText/AttributedText.swift @@ -1,10 +1,10 @@ -#if canImport(SwiftUI) && !os(watchOS) +#if canImport(SwiftUI) && !os(watchOS) && !targetEnvironment(macCatalyst) import SwiftUI @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public struct AttributedText: View { - @StateObject private var store = AttributedTextStore() + @StateObject private var store = TextViewStore() private let attributedText: NSAttributedString @@ -14,13 +14,17 @@ public var body: some View { GeometryReader { proxy in - AttributedTextViewWrapper(attributedText: attributedText, store: store) + TextViewWrapper(attributedText: attributedText, store: store) .preference(key: ContainerSizePreference.self, value: proxy.size) } .onPreferenceChange(ContainerSizePreference.self) { value in store.onContainerSizeChange(value) } - .frame(height: store.height) + .frame( + idealWidth: store.intrinsicContentSize?.width, + idealHeight: store.intrinsicContentSize?.height + ) + .fixedSize(horizontal: false, vertical: true) } } @@ -29,7 +33,7 @@ static var defaultValue: CGSize? static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { - value = value ?? nextValue() + value = nextValue() } } diff --git a/Sources/AttributedText/AttributedTextStore.swift b/Sources/AttributedText/AttributedTextStore.swift deleted file mode 100644 index da02c22..0000000 --- a/Sources/AttributedText/AttributedTextStore.swift +++ /dev/null @@ -1,27 +0,0 @@ -#if !os(watchOS) - - import CoreGraphics - import Foundation - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) - final class AttributedTextStore: ObservableObject { - @Published var height: CGFloat? - - var attributedTextView: AttributedTextView? - - func onContainerSizeChange(_ containerSize: CGSize?) { - guard let containerSize = containerSize, containerSize != .zero, - let attributedTextView = self.attributedTextView else { return } - - attributedTextView.preferredMaxLayoutWidth = containerSize.width - height = attributedTextView.intrinsicContentSize.height - } - - func onUpdateView() { - guard let attributedTextView = self.attributedTextView, - attributedTextView.preferredMaxLayoutWidth > 0 else { return } - height = attributedTextView.intrinsicContentSize.height - } - } - -#endif diff --git a/Sources/AttributedText/AttributedTextView+AppKit.swift b/Sources/AttributedText/AttributedTextView+AppKit.swift deleted file mode 100644 index 4698da7..0000000 --- a/Sources/AttributedText/AttributedTextView+AppKit.swift +++ /dev/null @@ -1,104 +0,0 @@ -#if canImport(SwiftUI) && os(macOS) - - import SwiftUI - - @available(macOS 11.0, *) - class AttributedTextView: NSView, NSTextViewDelegate { - var preferredMaxLayoutWidth: CGFloat = 0 { - didSet { - guard preferredMaxLayoutWidth != oldValue else { return } - textView.textContainer?.containerSize = CGSize( - width: preferredMaxLayoutWidth, - height: CGFloat.infinity - ) - invalidateIntrinsicContentSize() - } - } - - var attributedText: NSAttributedString { - get { textView.attributedString() } - set { - textView.textStorage?.setAttributedString(newValue) - invalidateIntrinsicContentSize() - } - } - - var numberOfLines: Int { - get { textView.textContainer?.maximumNumberOfLines ?? 0 } - set { - textView.textContainer?.maximumNumberOfLines = newValue - invalidateIntrinsicContentSize() - } - } - - var lineBreakMode: NSLineBreakMode { - get { textView.textContainer?.lineBreakMode ?? .byWordWrapping } - set { - textView.textContainer?.lineBreakMode = newValue - invalidateIntrinsicContentSize() - } - } - - var openURL: OpenURLAction? - - override var intrinsicContentSize: CGSize { - if let size = cachedIntrinsicContentSize { return size } - - guard let textContainer = textView.textContainer, - let layoutManager = textView.layoutManager - else { - return textView.intrinsicContentSize - } - - layoutManager.ensureLayout(for: textContainer) - let size = layoutManager.usedRect(for: textContainer).size - cachedIntrinsicContentSize = size - - return size - } - - private let textView = NSTextView() - private var cachedIntrinsicContentSize: CGSize? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setUp() - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layout() { - super.layout() - textView.frame = bounds - } - - override func invalidateIntrinsicContentSize() { - cachedIntrinsicContentSize = nil - super.invalidateIntrinsicContentSize() - } - - func textView(_: NSTextView, clickedOnLink link: Any, at _: Int) -> Bool { - guard let url = (link as? URL) ?? (link as? String).flatMap(URL.init(string:)) else { - return false - } - - openURL?(url) - return false - } - - private func setUp() { - addSubview(textView) - - textView.drawsBackground = false - textView.textContainerInset = .zero - textView.isEditable = false - textView.isRichText = false - textView.textContainer?.lineFragmentPadding = 0 - textView.delegate = self - } - } - -#endif diff --git a/Sources/AttributedText/AttributedTextView+UIKit.swift b/Sources/AttributedText/AttributedTextView+UIKit.swift deleted file mode 100644 index 879025b..0000000 --- a/Sources/AttributedText/AttributedTextView+UIKit.swift +++ /dev/null @@ -1,101 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) - - import SwiftUI - - @available(iOS 14.0, tvOS 14.0, *) - class AttributedTextView: UIView, UITextViewDelegate { - var preferredMaxLayoutWidth: CGFloat = 0 { - didSet { - guard preferredMaxLayoutWidth != oldValue else { return } - invalidateIntrinsicContentSize() - } - } - - var attributedText: NSAttributedString { - get { textView.attributedText } - set { - textView.attributedText = newValue - invalidateIntrinsicContentSize() - } - } - - var numberOfLines: Int { - get { textView.textContainer.maximumNumberOfLines } - set { - textView.textContainer.maximumNumberOfLines = newValue - invalidateIntrinsicContentSize() - } - } - - var lineBreakMode: NSLineBreakMode { - get { textView.textContainer.lineBreakMode } - set { - textView.textContainer.lineBreakMode = newValue - invalidateIntrinsicContentSize() - } - } - - var openURL: OpenURLAction? - - override var intrinsicContentSize: CGSize { - if let size = cachedIntrinsicContentSize { return size } - - let maxWidth = preferredMaxLayoutWidth > 0 ? preferredMaxLayoutWidth : bounds.width - guard maxWidth > 0 else { return textView.intrinsicContentSize } - - let size = textView.sizeThatFits(CGSize(width: maxWidth, height: .infinity)) - cachedIntrinsicContentSize = size - - return size - } - - private let textView = UITextView() - private var cachedIntrinsicContentSize: CGSize? - - override init(frame: CGRect) { - super.init(frame: frame) - setUp() - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - textView.frame = bounds - } - - override func invalidateIntrinsicContentSize() { - cachedIntrinsicContentSize = nil - super.invalidateIntrinsicContentSize() - } - - func textView( - _: UITextView, - shouldInteractWith URL: URL, - in _: NSRange, - interaction _: UITextItemInteraction - ) -> Bool { - openURL?(URL) - return false - } - - private func setUp() { - addSubview(textView) - - backgroundColor = .clear - - textView.textContainerInset = .zero - #if !os(tvOS) - textView.isEditable = false - #endif - textView.isScrollEnabled = false - textView.backgroundColor = .clear - textView.textContainer.lineFragmentPadding = 0 - textView.delegate = self - } - } - -#endif diff --git a/Sources/AttributedText/AttributedTextViewWrapper+AppKit.swift b/Sources/AttributedText/AttributedTextViewWrapper+AppKit.swift deleted file mode 100644 index 60521e8..0000000 --- a/Sources/AttributedText/AttributedTextViewWrapper+AppKit.swift +++ /dev/null @@ -1,27 +0,0 @@ -#if canImport(SwiftUI) && os(macOS) - - import SwiftUI - - @available(macOS 11.0, *) - struct AttributedTextViewWrapper: NSViewRepresentable { - let attributedText: NSAttributedString - let store: AttributedTextStore - - func makeNSView(context _: Context) -> AttributedTextView { - let nsView = AttributedTextView() - store.attributedTextView = nsView - - return nsView - } - - func updateNSView(_ nsView: AttributedTextView, context: Context) { - nsView.attributedText = attributedText - nsView.numberOfLines = context.environment.lineLimit ?? 0 - nsView.lineBreakMode = NSLineBreakMode(truncationMode: context.environment.truncationMode) - nsView.openURL = context.environment.openURL - - store.onUpdateView() - } - } - -#endif diff --git a/Sources/AttributedText/AttributedTextViewWrapper+UIKit.swift b/Sources/AttributedText/AttributedTextViewWrapper+UIKit.swift deleted file mode 100644 index 57bbd02..0000000 --- a/Sources/AttributedText/AttributedTextViewWrapper+UIKit.swift +++ /dev/null @@ -1,27 +0,0 @@ -#if canImport(SwiftUI) && canImport(UIKit) && !os(watchOS) - - import SwiftUI - - @available(iOS 14.0, tvOS 14.0, *) - struct AttributedTextViewWrapper: UIViewRepresentable { - let attributedText: NSAttributedString - let store: AttributedTextStore - - func makeUIView(context _: Context) -> AttributedTextView { - let uiView = AttributedTextView() - store.attributedTextView = uiView - - return uiView - } - - func updateUIView(_ uiView: AttributedTextView, context: Context) { - uiView.attributedText = attributedText - uiView.numberOfLines = context.environment.lineLimit ?? 0 - uiView.lineBreakMode = NSLineBreakMode(truncationMode: context.environment.truncationMode) - uiView.openURL = context.environment.openURL - - store.onUpdateView() - } - } - -#endif diff --git a/Sources/AttributedText/NSLineBreakMode+TruncationMode.swift b/Sources/AttributedText/NSLineBreakMode+TruncationMode.swift index 875a55c..6f7e7b0 100644 --- a/Sources/AttributedText/NSLineBreakMode+TruncationMode.swift +++ b/Sources/AttributedText/NSLineBreakMode+TruncationMode.swift @@ -1,4 +1,4 @@ -#if canImport(SwiftUI) && !os(watchOS) +#if canImport(SwiftUI) && !os(watchOS) && !targetEnvironment(macCatalyst) import SwiftUI diff --git a/Sources/AttributedText/TextViewStore.swift b/Sources/AttributedText/TextViewStore.swift new file mode 100644 index 0000000..04520b1 --- /dev/null +++ b/Sources/AttributedText/TextViewStore.swift @@ -0,0 +1,25 @@ +#if canImport(SwiftUI) && !os(watchOS) && !targetEnvironment(macCatalyst) + + import SwiftUI + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) + final class TextViewStore: ObservableObject { + @Published var intrinsicContentSize: CGSize? + + weak var view: TextViewWrapper.View? + + func onContainerSizeChange(_ containerSize: CGSize?) { + guard let containerSize = containerSize, + let view = self.view else { return } + + view.maxWidth = containerSize.width + } + + func didInvalidateIntrinsicContentSize() { + guard let view = self.view else { return } + + intrinsicContentSize = view.intrinsicContentSize + } + } + +#endif diff --git a/Sources/AttributedText/TextViewWrapper+NSTextView.swift b/Sources/AttributedText/TextViewWrapper+NSTextView.swift new file mode 100644 index 0000000..0fbbba2 --- /dev/null +++ b/Sources/AttributedText/TextViewWrapper+NSTextView.swift @@ -0,0 +1,93 @@ +#if canImport(SwiftUI) && os(macOS) + + import SwiftUI + + @available(macOS 11.0, *) + struct TextViewWrapper: NSViewRepresentable { + final class View: NSTextView { + weak var store: TextViewStore? + + var maxWidth: CGFloat { + get { textContainer?.containerSize.width ?? 0 } + set { + guard textContainer?.containerSize.width != newValue else { return } + textContainer?.containerSize.width = newValue + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: NSSize { + guard maxWidth > 0, + let textContainer = self.textContainer, + let layoutManager = self.layoutManager + else { + return super.intrinsicContentSize + } + + layoutManager.ensureLayout(for: textContainer) + return layoutManager.usedRect(for: textContainer).size + } + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + store?.didInvalidateIntrinsicContentSize() + } + + func setAttributedText(_ attributedText: NSAttributedString) { + // Avoid notifiying the store while the text storage is processing edits + let store = self.store + self.store = nil + textStorage?.setAttributedString(attributedText) + self.store = store + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var openURL: OpenURLAction? + + func textView(_: NSTextView, clickedOnLink link: Any, at _: Int) -> Bool { + guard let url = (link as? URL) ?? (link as? String).flatMap(URL.init(string:)) else { + return false + } + + openURL?(url) + return false + } + } + + let attributedText: NSAttributedString + let store: TextViewStore + + func makeNSView(context: Context) -> View { + let nsView = View(frame: .zero) + + nsView.drawsBackground = false + nsView.textContainerInset = .zero + nsView.isEditable = false + nsView.isRichText = false + nsView.textContainer?.lineFragmentPadding = 0 + // we are setting the container's width manually + nsView.textContainer?.widthTracksTextView = false + nsView.delegate = context.coordinator + + nsView.store = store + store.view = nsView + + return nsView + } + + func updateNSView(_ nsView: View, context: Context) { + nsView.setAttributedText(attributedText) + nsView.textContainer?.maximumNumberOfLines = context.environment.lineLimit ?? 0 + nsView.textContainer?.lineBreakMode = NSLineBreakMode(truncationMode: context.environment.truncationMode) + nsView.invalidateIntrinsicContentSize() + + context.coordinator.openURL = context.environment.openURL + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + } + +#endif diff --git a/Sources/AttributedText/TextViewWrapper+UITextView.swift b/Sources/AttributedText/TextViewWrapper+UITextView.swift new file mode 100644 index 0000000..87df9cf --- /dev/null +++ b/Sources/AttributedText/TextViewWrapper+UITextView.swift @@ -0,0 +1,85 @@ +#if canImport(UIKit) && !os(watchOS) && !targetEnvironment(macCatalyst) + + import SwiftUI + + @available(iOS 14.0, tvOS 14.0, *) + struct TextViewWrapper: UIViewRepresentable { + final class View: UITextView { + weak var store: TextViewStore? + + var maxWidth: CGFloat = 0 { + didSet { + guard maxWidth != oldValue else { return } + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + guard maxWidth > 0 else { + return super.intrinsicContentSize + } + + return sizeThatFits( + CGSize(width: maxWidth, height: .greatestFiniteMagnitude) + ) + } + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + store?.didInvalidateIntrinsicContentSize() + } + + func setAttributedText(_ attributedText: NSAttributedString) { + // Avoid notifiying the store while the text storage is processing edits + let store = self.store + self.store = nil + self.attributedText = attributedText + self.store = store + } + } + + final class Coordinator: NSObject, UITextViewDelegate { + var openURL: OpenURLAction? + + func textView(_: UITextView, shouldInteractWith URL: URL, in _: NSRange, interaction _: UITextItemInteraction) -> Bool { + openURL?(URL) + return false + } + } + + let attributedText: NSAttributedString + let store: TextViewStore + + func makeUIView(context: Context) -> View { + let uiView = View() + + uiView.backgroundColor = .clear + uiView.textContainerInset = .zero + #if !os(tvOS) + uiView.isEditable = false + #endif + uiView.isScrollEnabled = false + uiView.textContainer.lineFragmentPadding = 0 + uiView.delegate = context.coordinator + + uiView.store = store + store.view = uiView + + return uiView + } + + func updateUIView(_ uiView: View, context: Context) { + uiView.setAttributedText(attributedText) + uiView.textContainer.maximumNumberOfLines = context.environment.lineLimit ?? 0 + uiView.textContainer.lineBreakMode = NSLineBreakMode(truncationMode: context.environment.truncationMode) + uiView.invalidateIntrinsicContentSize() + + context.coordinator.openURL = context.environment.openURL + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + } + +#endif diff --git a/Tests/AttributedTextTests/AttributedTextTests.swift b/Tests/AttributedTextTests/AttributedTextTests.swift index bff12cb..00f1f54 100644 --- a/Tests/AttributedTextTests/AttributedTextTests.swift +++ b/Tests/AttributedTextTests/AttributedTextTests.swift @@ -1,4 +1,4 @@ -#if canImport(SwiftUI) && !os(macOS) +#if canImport(SwiftUI) && !os(macOS) && !targetEnvironment(macCatalyst) import SnapshotTesting import SwiftUI