├── .github
├── CODE_OF_CONDUCT.md
└── workflows
│ ├── ci.yml
│ └── format.yml
├── .gitignore
├── .swift-version
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Makefile
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
└── AttributedText
│ ├── AttributedText.swift
│ ├── AttributedTextImpl+iOS.swift
│ ├── AttributedTextImpl+macOS.swift
│ ├── AttributedTextImpl+tvOS.swift
│ ├── AttributedTextImpl.swift
│ ├── NSLineBreakMode+TruncationMode.swift
│ └── TextSizeViewModel.swift
├── Tests
└── AttributedTextTests
│ ├── AttributedTextTests.swift
│ └── __Snapshots__
│ └── AttributedTextTests
│ ├── testHeight.iOS.png
│ ├── testHeight.tvOS.png
│ ├── testLineLimit.iOS.png
│ ├── testLineLimit.tvOS.png
│ ├── testTruncationMode.iOS.png
│ └── testTruncationMode.tvOS.png
└── iOS_screenshot.png
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | [INSERT CONTACT METHOD].
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | branches:
8 | - '*'
9 | jobs:
10 | tests:
11 | runs-on: macos-11
12 | steps:
13 | - uses: actions/checkout@v2
14 | - name: Select Xcode 13.2.1
15 | run: sudo xcode-select -s /Applications/Xcode_13.2.1.app
16 | - name: Run tests
17 | run: make test
18 |
--------------------------------------------------------------------------------
/.github/workflows/format.yml:
--------------------------------------------------------------------------------
1 | name: Format
2 | on:
3 | push:
4 | branches:
5 | - main
6 | jobs:
7 | format:
8 | name: swift-format
9 | runs-on: macos-10.15
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Tap
13 | run: brew tap pointfreeco/formulae
14 | - name: Install
15 | run: brew install Formulae/swift-format@5.3
16 | - name: Format
17 | run: make format
18 | - uses: stefanzweifel/git-auto-commit-action@v4
19 | with:
20 | commit_message: Run swift format
21 | branch: 'main'
22 | env:
23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
--------------------------------------------------------------------------------
/.swift-version:
--------------------------------------------------------------------------------
1 | 5.3
2 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Guille Gonzalez
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | test-macos:
2 | xcodebuild \
3 | -scheme AttributedText \
4 | -destination platform="macOS"
5 |
6 | test-ios:
7 | xcodebuild test \
8 | -scheme AttributedText \
9 | -destination platform="iOS Simulator,name=iPhone 8"
10 |
11 | test-tvos:
12 | xcodebuild test \
13 | -scheme AttributedText \
14 | -destination platform="tvOS Simulator,name=Apple TV"
15 |
16 | test: test-macos test-ios test-tvos
17 |
18 | format:
19 | swift format --in-place --recursive .
20 |
21 | .PHONY: format
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "SnapshotTesting",
6 | "repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing",
7 | "state": {
8 | "branch": null,
9 | "revision": "f8a9c997c3c1dab4e216a8ec9014e23144cbab37",
10 | "version": "1.9.0"
11 | }
12 | }
13 | ]
14 | },
15 | "version": 1
16 | }
17 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "AttributedText",
8 | platforms: [
9 | .macOS(.v11),
10 | .iOS(.v14),
11 | .tvOS(.v14),
12 | ],
13 | products: [
14 | .library(
15 | name: "AttributedText",
16 | targets: ["AttributedText"]
17 | )
18 | ],
19 | dependencies: [
20 | .package(
21 | name: "SnapshotTesting",
22 | url: "https://github.com/pointfreeco/swift-snapshot-testing",
23 | from: "1.9.0"
24 | )
25 | ],
26 | targets: [
27 | .target(
28 | name: "AttributedText",
29 | dependencies: []
30 | ),
31 | .testTarget(
32 | name: "AttributedTextTests",
33 | dependencies: ["AttributedText", "SnapshotTesting"],
34 | exclude: ["__Snapshots__"]
35 | ),
36 | ]
37 | )
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > **Warning**
2 | >
3 | > **This repo has been archived.**
4 | >
5 | > From macOS 12+ / iOS 15+ / tvOS 15+ / watchOS 8+, you can use `AttributedString` with the SwiftUI `Text` view.
6 |
7 | # AttributedText
8 | [](https://swiftpackageindex.com/gonzalezreal/AttributedText)
9 | [](https://swiftpackageindex.com/gonzalezreal/AttributedText)
10 | [](https://twitter.com/gonzalezreal)
11 |
12 | AttributedText is a Swift µpackage that provides `NSAttributedString` rendering in SwiftUI by wrapping either an `NSTextView` or a `UITextView` depending on the platform.
13 |
14 | ## Supported Platforms
15 |
16 | * macOS 11.0+
17 | * iOS 14.0+
18 | * tvOS 14.0+
19 |
20 | ## Usage
21 | ```swift
22 | import AttributedText
23 | import SwiftUI
24 |
25 | struct ContentView: View {
26 | var body: some View {
27 | AttributedText {
28 | let result = NSMutableAttributedString(
29 | string: """
30 | After the Big Bang
31 | A brief summary of time
32 | Life on earth
33 | 10 billion years
34 | You reading this
35 | 13.7 billion years
36 | """
37 | )
38 |
39 | result.addAttributes(
40 | [.font: UIFont.preferredFont(forTextStyle: .title1)],
41 | range: NSRange(location: 0, length: 18)
42 | )
43 | result.addAttributes(
44 | [.link: URL(string: "https://en.wikipedia.org/wiki/Big_Bang")!],
45 | range: NSRange(location: 10, length: 8)
46 | )
47 | result.addAttributes(
48 | [.font: UIFont.preferredFont(forTextStyle: .body)],
49 | range: NSRange(location: 19, length: 23)
50 | )
51 | result.addAttributes(
52 | [.font: UIFont.preferredFont(forTextStyle: .title2)],
53 | range: NSRange(location: 43, length: 13)
54 | )
55 | result.addAttributes(
56 | [.font: UIFont.preferredFont(forTextStyle: .body)],
57 | range: NSRange(location: 57, length: 16)
58 | )
59 | result.addAttributes(
60 | [.font: UIFont.preferredFont(forTextStyle: .title2)],
61 | range: NSRange(location: 74, length: 16))
62 | result.addAttributes(
63 | [.font: UIFont.preferredFont(forTextStyle: .body)],
64 | range: NSRange(location: 91, length: 18)
65 | )
66 |
67 | return result
68 | }
69 | .background(Color.gray.opacity(0.5))
70 | .accentColor(.purple)
71 | }
72 | }
73 | ```
74 |
75 | 
76 |
77 | An `AttributedText` view takes all the available width and adjusts its height to fit the contents.
78 |
79 | To change the text alignment or line break mode, you need to add a `.paragraphStyle` attribute to the attributed string.
80 |
81 | ## Installation
82 | You can add AttributedText to an Xcode project by adding it as a package dependency.
83 | 1. From the **File** menu, select **Swift Packages › Add Package Dependency…**
84 | 1. Enter `https://github.com/gonzalezreal/AttributedText` into the package repository URL text field
85 | 1. Link **AttributedText** to your application target
86 |
--------------------------------------------------------------------------------
/Sources/AttributedText/AttributedText.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A view that displays styled attributed text.
4 | public struct AttributedText: View {
5 | private var textSizeViewModel = TextSizeViewModel()
6 | @State private var textSize: CGSize?
7 |
8 | private let attributedText: NSAttributedString
9 | private let onOpenLink: ((URL) -> Void)?
10 |
11 | /// Creates an attributed text view.
12 | /// - Parameters:
13 | /// - attributedText: An attributed string to display.
14 | /// - onOpenLink: The action to perform when the user opens a link in the text. When not specified,
15 | /// the view opens the links using the `OpenURLAction` from the environment.
16 | public init(_ attributedText: NSAttributedString, onOpenLink: ((URL) -> Void)? = nil) {
17 | self.attributedText = attributedText
18 | self.onOpenLink = onOpenLink
19 | }
20 |
21 | /// Creates an attributed text view.
22 | /// - Parameters:
23 | /// - attributedText: A closure that creates the attributed string to display.
24 | /// - onOpenLink: The action to perform when the user opens a link in the text. When not specified,
25 | /// the view opens the links using the `OpenURLAction` from the environment.
26 | public init(attributedText: () -> NSAttributedString, onOpenLink: ((URL) -> Void)? = nil) {
27 | self.init(attributedText(), onOpenLink: onOpenLink)
28 | }
29 |
30 | public var body: some View {
31 | GeometryReader { geometry in
32 | AttributedTextImpl(
33 | attributedText: attributedText,
34 | maxLayoutWidth: geometry.maxWidth,
35 | textSizeViewModel: textSizeViewModel,
36 | onOpenLink: onOpenLink
37 | )
38 | }
39 | .frame(
40 | idealWidth: textSize?.width,
41 | idealHeight: textSize?.height
42 | )
43 | .fixedSize(horizontal: false, vertical: true)
44 | .onReceive(textSizeViewModel.$textSize) { size in
45 | textSize = size
46 | }
47 | }
48 | }
49 |
50 | extension GeometryProxy {
51 | fileprivate var maxWidth: CGFloat {
52 | size.width - safeAreaInsets.leading - safeAreaInsets.trailing
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/AttributedText/AttributedTextImpl+iOS.swift:
--------------------------------------------------------------------------------
1 | #if os(iOS)
2 | import SwiftUI
3 |
4 | extension AttributedTextImpl: UIViewRepresentable {
5 | func makeUIView(context: Context) -> TextView {
6 | TextView()
7 | }
8 |
9 | func updateUIView(_ uiView: TextView, context: Context) {
10 | uiView.attributedText = attributedText
11 | uiView.maxLayoutWidth = maxLayoutWidth
12 |
13 | uiView.textContainer.maximumNumberOfLines = context.environment.lineLimit ?? 0
14 | uiView.textContainer.lineBreakMode = NSLineBreakMode(
15 | truncationMode: context.environment.truncationMode
16 | )
17 | uiView.openLink = onOpenLink ?? { context.environment.openURL($0) }
18 | textSizeViewModel.didUpdateTextView(uiView)
19 | }
20 | }
21 |
22 | extension AttributedTextImpl {
23 | final class TextView: UITextView {
24 | var maxLayoutWidth: CGFloat = 0 {
25 | didSet {
26 | guard maxLayoutWidth != oldValue else { return }
27 | invalidateIntrinsicContentSize()
28 | }
29 | }
30 |
31 | var openLink: ((URL) -> Void)?
32 |
33 | override init(frame: CGRect, textContainer: NSTextContainer?) {
34 | super.init(frame: frame, textContainer: textContainer)
35 |
36 | self.backgroundColor = .clear
37 | self.textContainerInset = .zero
38 | self.isEditable = false
39 | self.isSelectable = false
40 | self.isScrollEnabled = false
41 | self.textContainer.lineFragmentPadding = 0
42 |
43 | self.addGestureRecognizer(
44 | UITapGestureRecognizer(target: self, action: #selector(handleTap(sender:)))
45 | )
46 | }
47 |
48 | required init?(coder: NSCoder) {
49 | fatalError("init(coder:) has not been implemented")
50 | }
51 |
52 | override var intrinsicContentSize: CGSize {
53 | guard maxLayoutWidth > 0 else {
54 | return super.intrinsicContentSize
55 | }
56 |
57 | return sizeThatFits(CGSize(width: maxLayoutWidth, height: .greatestFiniteMagnitude))
58 | }
59 |
60 | @objc func handleTap(sender: UITapGestureRecognizer) {
61 | guard let url = self.url(at: sender.location(in: self)) else {
62 | return
63 | }
64 | openLink?(url)
65 | }
66 |
67 | private func url(at location: CGPoint) -> URL? {
68 | guard let attributedText = self.attributedText else { return nil }
69 |
70 | let index = indexOfCharacter(at: location)
71 | return attributedText.attribute(.link, at: index, effectiveRange: nil) as? URL
72 | }
73 |
74 | private func indexOfCharacter(at location: CGPoint) -> Int {
75 | let locationInTextContainer = CGPoint(
76 | x: location.x - self.textContainerInset.left,
77 | y: location.y - self.textContainerInset.top
78 | )
79 | return self.layoutManager.characterIndex(
80 | for: locationInTextContainer,
81 | in: self.textContainer,
82 | fractionOfDistanceBetweenInsertionPoints: nil
83 | )
84 | }
85 | }
86 | }
87 | #endif
88 |
--------------------------------------------------------------------------------
/Sources/AttributedText/AttributedTextImpl+macOS.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import SwiftUI
3 |
4 | extension AttributedTextImpl: NSViewRepresentable {
5 | func makeNSView(context: Context) -> TextView {
6 | let nsView = TextView(frame: .zero)
7 |
8 | nsView.drawsBackground = false
9 | nsView.textContainerInset = .zero
10 | nsView.isEditable = false
11 | nsView.isRichText = false
12 | nsView.textContainer?.lineFragmentPadding = 0
13 | // we are setting the container's width manually
14 | nsView.textContainer?.widthTracksTextView = false
15 | nsView.delegate = context.coordinator
16 |
17 | return nsView
18 | }
19 |
20 | func updateNSView(_ nsView: TextView, context: Context) {
21 | nsView.textStorage?.setAttributedString(attributedText)
22 | nsView.maxLayoutWidth = maxLayoutWidth
23 |
24 | nsView.textContainer?.maximumNumberOfLines = context.environment.lineLimit ?? 0
25 | nsView.textContainer?.lineBreakMode = NSLineBreakMode(
26 | truncationMode: context.environment.truncationMode
27 | )
28 | context.coordinator.openLink = onOpenLink ?? { context.environment.openURL($0) }
29 | textSizeViewModel.didUpdateTextView(nsView)
30 | }
31 |
32 | func makeCoordinator() -> Coordinator {
33 | Coordinator()
34 | }
35 | }
36 |
37 | extension AttributedTextImpl {
38 | final class TextView: NSTextView {
39 | var maxLayoutWidth: CGFloat {
40 | get { textContainer?.containerSize.width ?? 0 }
41 | set {
42 | guard textContainer?.containerSize.width != newValue else { return }
43 | textContainer?.containerSize.width = newValue
44 | invalidateIntrinsicContentSize()
45 | }
46 | }
47 |
48 | override var intrinsicContentSize: NSSize {
49 | guard maxLayoutWidth > 0,
50 | let textContainer = self.textContainer,
51 | let layoutManager = self.layoutManager
52 | else {
53 | return super.intrinsicContentSize
54 | }
55 |
56 | layoutManager.ensureLayout(for: textContainer)
57 | return layoutManager.usedRect(for: textContainer).size
58 | }
59 | }
60 |
61 | final class Coordinator: NSObject, NSTextViewDelegate {
62 | var openLink: ((URL) -> Void)?
63 |
64 | func textView(_: NSTextView, clickedOnLink link: Any, at _: Int) -> Bool {
65 | guard let openLink = self.openLink,
66 | let url = (link as? URL) ?? (link as? String).flatMap(URL.init(string:))
67 | else {
68 | return false
69 | }
70 |
71 | openLink(url)
72 | return true
73 | }
74 | }
75 | }
76 | #endif
77 |
--------------------------------------------------------------------------------
/Sources/AttributedText/AttributedTextImpl+tvOS.swift:
--------------------------------------------------------------------------------
1 | #if os(tvOS)
2 | import SwiftUI
3 |
4 | extension AttributedTextImpl: UIViewRepresentable {
5 | func makeUIView(context: Context) -> TextView {
6 | let uiView = TextView()
7 |
8 | uiView.backgroundColor = .clear
9 | uiView.textContainerInset = .zero
10 | uiView.isScrollEnabled = false
11 | uiView.textContainer.lineFragmentPadding = 0
12 | uiView.delegate = context.coordinator
13 |
14 | return uiView
15 | }
16 |
17 | func updateUIView(_ uiView: TextView, context: Context) {
18 | uiView.attributedText = attributedText
19 | uiView.maxLayoutWidth = maxLayoutWidth
20 |
21 | uiView.textContainer.maximumNumberOfLines = context.environment.lineLimit ?? 0
22 | uiView.textContainer.lineBreakMode = NSLineBreakMode(
23 | truncationMode: context.environment.truncationMode
24 | )
25 | context.coordinator.openLink = onOpenLink ?? { context.environment.openURL($0) }
26 | textSizeViewModel.didUpdateTextView(uiView)
27 | }
28 |
29 | func makeCoordinator() -> Coordinator {
30 | Coordinator()
31 | }
32 | }
33 |
34 | extension AttributedTextImpl {
35 | final class TextView: UITextView {
36 | var maxLayoutWidth: CGFloat = 0 {
37 | didSet {
38 | guard maxLayoutWidth != oldValue else { return }
39 | invalidateIntrinsicContentSize()
40 | }
41 | }
42 |
43 | override var intrinsicContentSize: CGSize {
44 | guard maxLayoutWidth > 0 else {
45 | return super.intrinsicContentSize
46 | }
47 |
48 | return sizeThatFits(CGSize(width: maxLayoutWidth, height: .greatestFiniteMagnitude))
49 | }
50 | }
51 |
52 | final class Coordinator: NSObject, UITextViewDelegate {
53 | var openLink: ((URL) -> Void)?
54 |
55 | func textView(
56 | _: UITextView,
57 | shouldInteractWith URL: URL,
58 | in _: NSRange,
59 | interaction: UITextItemInteraction
60 | ) -> Bool {
61 | guard case .invokeDefaultAction = interaction else {
62 | return false
63 | }
64 |
65 | if let openLink = self.openLink {
66 | openLink(URL)
67 | return false
68 | } else {
69 | return true
70 | }
71 | }
72 |
73 | func textView(
74 | _: UITextView,
75 | shouldInteractWith _: NSTextAttachment,
76 | in _: NSRange,
77 | interaction _: UITextItemInteraction
78 | ) -> Bool {
79 | // Disable text attachment interactions
80 | false
81 | }
82 | }
83 | }
84 | #endif
85 |
--------------------------------------------------------------------------------
/Sources/AttributedText/AttributedTextImpl.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct AttributedTextImpl {
4 | var attributedText: NSAttributedString
5 | var maxLayoutWidth: CGFloat
6 | var textSizeViewModel: TextSizeViewModel
7 | var onOpenLink: ((URL) -> Void)?
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/AttributedText/NSLineBreakMode+TruncationMode.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension NSLineBreakMode {
4 | init(truncationMode: Text.TruncationMode) {
5 | switch truncationMode {
6 | case .head:
7 | self = .byTruncatingHead
8 | case .tail:
9 | self = .byTruncatingTail
10 | case .middle:
11 | self = .byTruncatingMiddle
12 | @unknown default:
13 | self = .byWordWrapping
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/AttributedText/TextSizeViewModel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | final class TextSizeViewModel {
4 | @Published var textSize: CGSize?
5 |
6 | func didUpdateTextView(_ textView: AttributedTextImpl.TextView) {
7 | textSize = textView.intrinsicContentSize
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Tests/AttributedTextTests/AttributedTextTests.swift:
--------------------------------------------------------------------------------
1 | #if !os(macOS) && !targetEnvironment(macCatalyst)
2 | import SnapshotTesting
3 | import SwiftUI
4 | import XCTest
5 |
6 | import AttributedText
7 |
8 | final class AttributedTextTests: XCTestCase {
9 | struct TestView: View {
10 | var body: some View {
11 | AttributedText {
12 | let result = NSMutableAttributedString(
13 | string: """
14 | The Adventures of Sherlock Holmes
15 | I had called upon my friend, Mr. Sherlock Holmes, one day in the autumn of last year and found him in deep conversation with a very stout, florid-faced, elderly gentleman with fiery red hair.
16 | """
17 | )
18 |
19 | result.addAttributes(
20 | [.font: UIFont.preferredFont(forTextStyle: .title2)],
21 | range: NSRange(location: 0, length: 33)
22 | )
23 | result.addAttributes(
24 | [.font: UIFont.preferredFont(forTextStyle: .body)],
25 | range: NSRange(location: 33, length: 192)
26 | )
27 | return result
28 | }
29 | .background(Color.gray.opacity(0.5))
30 | .padding()
31 | }
32 | }
33 |
34 | private let precision: Float = 0.99
35 |
36 | #if os(iOS)
37 | private let layout = SwiftUISnapshotLayout.device(config: .iPhone8)
38 | private let platformName = "iOS"
39 | #elseif os(tvOS)
40 | private let layout = SwiftUISnapshotLayout.device(config: .tv)
41 | private let platformName = "tvOS"
42 | #endif
43 |
44 | func testHeight() {
45 | let view = TestView()
46 | assertSnapshot(
47 | matching: view, as: .image(precision: precision, layout: layout), named: platformName)
48 | }
49 |
50 | func testLineLimit() {
51 | let view = TestView()
52 | .lineLimit(2)
53 | assertSnapshot(
54 | matching: view, as: .image(precision: precision, layout: layout), named: platformName)
55 | }
56 |
57 | func testTruncationMode() {
58 | let view = TestView()
59 | .lineLimit(2)
60 | .truncationMode(.middle)
61 | assertSnapshot(
62 | matching: view, as: .image(precision: precision, layout: layout), named: platformName)
63 | }
64 | }
65 | #endif
66 |
--------------------------------------------------------------------------------
/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testHeight.iOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gonzalezreal/AttributedText/039206ea7a698d349b3bd73d78023e0c0fd52841/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testHeight.iOS.png
--------------------------------------------------------------------------------
/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testHeight.tvOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gonzalezreal/AttributedText/039206ea7a698d349b3bd73d78023e0c0fd52841/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testHeight.tvOS.png
--------------------------------------------------------------------------------
/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testLineLimit.iOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gonzalezreal/AttributedText/039206ea7a698d349b3bd73d78023e0c0fd52841/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testLineLimit.iOS.png
--------------------------------------------------------------------------------
/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testLineLimit.tvOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gonzalezreal/AttributedText/039206ea7a698d349b3bd73d78023e0c0fd52841/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testLineLimit.tvOS.png
--------------------------------------------------------------------------------
/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testTruncationMode.iOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gonzalezreal/AttributedText/039206ea7a698d349b3bd73d78023e0c0fd52841/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testTruncationMode.iOS.png
--------------------------------------------------------------------------------
/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testTruncationMode.tvOS.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gonzalezreal/AttributedText/039206ea7a698d349b3bd73d78023e0c0fd52841/Tests/AttributedTextTests/__Snapshots__/AttributedTextTests/testTruncationMode.tvOS.png
--------------------------------------------------------------------------------
/iOS_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gonzalezreal/AttributedText/039206ea7a698d349b3bd73d78023e0c0fd52841/iOS_screenshot.png
--------------------------------------------------------------------------------