├── .swift-version
├── README
├── logo.png
├── main.png
├── logo@2x.png
├── test_links.png
├── test_uber.png
├── test_atributika_logo.png
├── test_attributedlabel.png
├── test_phone_numbers.png
└── test_hashtags_mentions.png
├── Demo
├── Assets.xcassets
│ ├── Contents.json
│ ├── scissors.imageset
│ │ ├── icons8-scissors-26.png
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── Info.plist
├── Base.lproj
│ └── LaunchScreen.storyboard
├── SnippetsViewController.swift
├── IBViewController.swift
├── AppDelegate.swift
├── AttributedLabelDemoViewController.swift
├── IB.storyboard
└── Snippet.swift
├── Tests
├── LinuxMain.swift
└── AtributikaTests
│ └── AtributikaTests.swift
├── Atributika.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── xcshareddata
│ └── xcschemes
│ ├── Atributika-watchOS.xcscheme
│ ├── Atributika-iOS.xcscheme
│ ├── Atributika-tvOS.xcscheme
│ └── Atributika-macOS.xcscheme
├── Sources
├── NSAttributedString+Utils.swift
├── NSScanner+Swift.swift
├── AttributedText.swift
├── String+Detection.swift
├── Style.swift
├── AttributedLabel.swift
└── HTMLSpecials.swift
├── Configs
├── AtributikaTests.plist
└── Atributika.plist
├── Package.swift
├── LICENSE
├── Atributika.podspec
├── .gitignore
├── .travis.yml
└── README.md
/.swift-version:
--------------------------------------------------------------------------------
1 | 4.0
2 |
--------------------------------------------------------------------------------
/README/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/logo.png
--------------------------------------------------------------------------------
/README/main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/main.png
--------------------------------------------------------------------------------
/README/logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/logo@2x.png
--------------------------------------------------------------------------------
/README/test_links.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/test_links.png
--------------------------------------------------------------------------------
/README/test_uber.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/test_uber.png
--------------------------------------------------------------------------------
/Demo/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/README/test_atributika_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/test_atributika_logo.png
--------------------------------------------------------------------------------
/README/test_attributedlabel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/test_attributedlabel.png
--------------------------------------------------------------------------------
/README/test_phone_numbers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/test_phone_numbers.png
--------------------------------------------------------------------------------
/README/test_hashtags_mentions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/README/test_hashtags_mentions.png
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import AtributikaTests
3 |
4 | XCTMain([
5 | testCase(AtributikaTests.allTests),
6 | ])
7 |
--------------------------------------------------------------------------------
/Demo/Assets.xcassets/scissors.imageset/icons8-scissors-26.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bcherry/Atributika/master/Demo/Assets.xcassets/scissors.imageset/icons8-scissors-26.png
--------------------------------------------------------------------------------
/Atributika.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Atributika.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Demo/Assets.xcassets/scissors.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "icons8-scissors-26.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/Sources/NSAttributedString+Utils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Created by Pavel Sharanda on 21.02.17.
3 | // Copyright © 2017 psharanda. All rights reserved.
4 | //
5 |
6 | import Foundation
7 |
8 | public func + (lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString {
9 | let s = NSMutableAttributedString(attributedString: lhs)
10 | s.append(rhs)
11 | return s
12 | }
13 |
14 | public func + (lhs: String, rhs: NSAttributedString) -> NSAttributedString {
15 | let s = NSMutableAttributedString(string: lhs)
16 | s.append(rhs)
17 | return s
18 | }
19 |
20 | public func + (lhs: NSAttributedString, rhs: String) -> NSAttributedString {
21 | let s = NSMutableAttributedString(attributedString: lhs)
22 | s.append(NSAttributedString(string: rhs))
23 | return s
24 | }
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/Configs/AtributikaTests.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.1
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(name: "Atributika",
7 | platforms: [.macOS(.v10_10),
8 | .iOS(.v8),
9 | .tvOS(.v9),
10 | .watchOS(.v2)],
11 | products: [.library(name: "Atributika",
12 | targets: ["Atributika"])],
13 | targets: [.target(name: "Atributika",
14 | path: "Sources"),
15 | .testTarget(
16 | name: "AtributikaTests",
17 | dependencies: ["Atributika"]),],
18 | swiftLanguageVersions: [.v5])
19 |
--------------------------------------------------------------------------------
/Configs/Atributika.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | $(CURRENT_PROJECT_VERSION)
23 | NSHumanReadableCopyright
24 | Copyright © 2017 Pavel Sharanda. All rights reserved.
25 | NSPrincipalClass
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ios-marketing",
45 | "size" : "1024x1024",
46 | "scale" : "1x"
47 | }
48 | ],
49 | "info" : {
50 | "version" : 1,
51 | "author" : "xcode"
52 | }
53 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Pavel Sharanda
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 |
23 |
--------------------------------------------------------------------------------
/Atributika.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = "Atributika"
3 | s.version = "4.9.9"
4 | s.summary = "Convert text with HTML tags, hashtags, mentions, links into NSAttributedString. Make them clickable with UILabel drop-in replacement."
5 | s.description = <<-DESC
6 | `Atributika` is an easy and painless way to build NSAttributedString. It is able to detect HTML-like tags, links, phone numbers, hashtags, any regex or even standard ios data detectors and style them with various attributes like font, color, etc. `Atributika` comes with drop-in label replacement `AttributedLabel` which is able to make any detection clickable.
7 | DESC
8 | s.homepage = "https://github.com/psharanda/Atributika"
9 | s.license = { :type => "MIT", :file => "LICENSE" }
10 | s.author = { "Pavel Sharanda" => "edvaef@gmail.com" }
11 | s.social_media_url = "https://twitter.com/e2f"
12 | s.ios.deployment_target = "8.0"
13 | s.osx.deployment_target = "10.10"
14 | s.watchos.deployment_target = "2.0"
15 | s.tvos.deployment_target = "9.0"
16 | s.source = { :git => "https://github.com/psharanda/Atributika.git", :tag => s.version.to_s }
17 | s.source_files = "Sources/**/*.swift"
18 | end
19 |
--------------------------------------------------------------------------------
/Demo/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UILaunchStoryboardName
24 | LaunchScreen
25 | UIRequiredDeviceCapabilities
26 |
27 | armv7
28 |
29 | UISupportedInterfaceOrientations
30 |
31 | UIInterfaceOrientationPortrait
32 | UIInterfaceOrientationLandscapeRight
33 | UIInterfaceOrientationLandscapeLeft
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/Sources/NSScanner+Swift.swift:
--------------------------------------------------------------------------------
1 | // NSScanner+Swift.swift
2 | // A set of Swift-idiomatic methods for NSScanner
3 | //
4 | // (c) 2015 Nate Cook, licensed under the MIT license
5 |
6 | import Foundation
7 |
8 | extension Scanner {
9 |
10 | // MARK: Strings
11 |
12 | /// Returns a string, scanned as long as characters from a given character set are encountered, or `nil` if none are found.
13 | func scanCharacters(from set: CharacterSet) -> String? {
14 | var value: NSString? = ""
15 | if scanCharacters(from: set, into: &value) {
16 | return value as String?
17 | }
18 | return nil
19 | }
20 |
21 | /// Returns a string, scanned until a character from a given character set are encountered, or the remainder of the scanner's string. Returns `nil` if the scanner is already `atEnd`.
22 | func scanUpToCharacters(from set: CharacterSet) -> String? {
23 | var value: NSString? = ""
24 | if scanUpToCharacters(from: set, into: &value) {
25 | return value as String?
26 | }
27 | return nil
28 | }
29 |
30 | /// Returns the given string if scanned, or `nil` if not found.
31 | @discardableResult func scanString(_ str: String) -> String? {
32 | var value: NSString? = ""
33 | if scanString(str, into: &value) {
34 | return value as String?
35 | }
36 | return nil
37 | }
38 |
39 | /// Returns a string, scanned until the given string is found, or the remainder of the scanner's string. Returns `nil` if the scanner is already `atEnd`.
40 | func scanUpTo(_ str: String) -> String? {
41 | var value: NSString? = ""
42 | if scanUpTo(str, into: &value) {
43 | return value as String?
44 | }
45 | return nil
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Demo/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/Demo/SnippetsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Created by Pavel Sharanda on 21.02.17.
3 | // Copyright © 2017 Pavel Sharanda. All rights reserved.
4 | //
5 |
6 | import UIKit
7 |
8 | class SnippetsViewController: UIViewController {
9 |
10 | private lazy var tableView: UITableView = {
11 | let tableView = UITableView(frame: CGRect(), style: .plain)
12 |
13 | tableView.delegate = self
14 | tableView.dataSource = self
15 | #if swift(>=4.2)
16 | tableView.rowHeight = UITableView.automaticDimension
17 | #else
18 | tableView.rowHeight = UITableViewAutomaticDimension
19 | #endif
20 | tableView.estimatedRowHeight = 50
21 | return tableView
22 | }()
23 |
24 | private var snippets = allSnippets()
25 |
26 | override func viewDidLoad() {
27 | super.viewDidLoad()
28 | view.addSubview(tableView)
29 | }
30 |
31 | override func viewDidLayoutSubviews() {
32 | super.viewDidLayoutSubviews()
33 | tableView.frame = view.bounds
34 | }
35 | }
36 |
37 | extension SnippetsViewController: UITableViewDelegate, UITableViewDataSource {
38 |
39 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
40 | return snippets.count
41 | }
42 |
43 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
44 | let cellId = "CellId"
45 | let cell = tableView.dequeueReusableCell(withIdentifier: cellId) ?? UITableViewCell(style: .default, reuseIdentifier: cellId)
46 | cell.textLabel?.attributedText = snippets[indexPath.row]
47 | cell.textLabel?.numberOfLines = 0
48 | return cell
49 | }
50 |
51 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
52 | tableView.deselectRow(at: indexPath, animated: true)
53 | }
54 | }
55 |
56 |
57 |
--------------------------------------------------------------------------------
/.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 | .DS_Store
92 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: objective-c
2 | osx_image: xcode8
3 | env:
4 | global:
5 | - LC_CTYPE=en_US.UTF-8
6 | - LANG=en_US.UTF-8
7 | - PROJECT=Atributika.xcodeproj
8 | - IOS_FRAMEWORK_SCHEME="Atributika-iOS"
9 | - MACOS_FRAMEWORK_SCHEME="Atributika-macOS"
10 | - TVOS_FRAMEWORK_SCHEME="Atributika-tvOS"
11 | - WATCHOS_FRAMEWORK_SCHEME="Atributika-watchOS"
12 | - IOS_SDK=iphonesimulator10.0
13 | - MACOS_SDK=macosx10.12
14 | - TVOS_SDK=appletvsimulator10.0
15 | - WATCHOS_SDK=watchsimulator3.0
16 | - EXAMPLE_SCHEME="Demo"
17 | matrix:
18 | - DESTINATION="OS=3.0,name=Apple Watch - 42mm" SCHEME="$WATCHOS_FRAMEWORK_SCHEME" SDK="$WATCHOS_SDK" RUN_TESTS="NO" BUILD_EXAMPLE="NO" POD_LINT="NO"
19 | - DESTINATION="OS=10.0,name=iPhone 7" SCHEME="$IOS_FRAMEWORK_SCHEME" SDK="$IOS_SDK" RUN_TESTS="YES" BUILD_EXAMPLE="YES" POD_LINT="YES"
20 | - DESTINATION="OS=10.0,name=Apple TV 1080p" SCHEME="$TVOS_FRAMEWORK_SCHEME" SDK="$TVOS_SDK" RUN_TESTS="YES" BUILD_EXAMPLE="NO" POD_LINT="NO"
21 | - DESTINATION="arch=x86_64" SCHEME="$MACOS_FRAMEWORK_SCHEME" SDK="$MACOS_SDK" RUN_TESTS="YES" BUILD_EXAMPLE="NO" POD_LINT="NO"
22 | before_install:
23 | - gem install cocoapods --pre --no-rdoc --no-ri --no-document --quiet
24 | script:
25 | - set -o pipefail
26 | - xcodebuild -version
27 | - xcodebuild -showsdks
28 |
29 | # Build Framework in Debug and Run Tests if specified
30 | - if [ $RUN_TESTS == "YES" ]; then
31 | xcodebuild -project "$PROJECT" -scheme "$SCHEME" -sdk "$SDK" -destination "$DESTINATION" -configuration Debug ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=YES test | xcpretty;
32 | else
33 | xcodebuild -project "$PROJECT" -scheme "$SCHEME" -sdk "$SDK" -destination "$DESTINATION" -configuration Debug ONLY_ACTIVE_ARCH=NO build | xcpretty;
34 | fi
35 |
36 | # Build Framework in Release and Run Tests if specified
37 | - if [ $RUN_TESTS == "YES" ]; then
38 | xcodebuild -project "$PROJECT" -scheme "$SCHEME" -sdk "$SDK" -destination "$DESTINATION" -configuration Release ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=YES test | xcpretty;
39 | else
40 | xcodebuild -project "$PROJECT" -scheme "$SCHEME" -sdk "$SDK" -destination "$DESTINATION" -configuration Release ONLY_ACTIVE_ARCH=NO build | xcpretty;
41 | fi
42 |
43 | # Build Example in Debug if specified
44 | - if [ $BUILD_EXAMPLE == "YES" ]; then
45 | xcodebuild -project "$PROJECT" -scheme "$EXAMPLE_SCHEME" -sdk "$SDK" -destination "$DESTINATION" -configuration Debug ONLY_ACTIVE_ARCH=NO build | xcpretty;
46 | fi
47 |
48 | # Run `pod lib lint` if specified
49 | - if [ $POD_LINT == "YES" ]; then
50 | pod lib lint;
51 | fi
--------------------------------------------------------------------------------
/Demo/IBViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IBViewController.swift
3 | // Demo
4 | //
5 | // Created by Pavel Sharanda on 12/17/19.
6 | // Copyright © 2019 Atributika. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Atributika
11 |
12 | class IBViewController: UIViewController {
13 |
14 | override func viewDidLoad() {
15 | super.viewDidLoad()
16 |
17 | let font: UIFont
18 | if #available(iOS 11.0, *) {
19 | font = UIFont.preferredFont(forTextStyle: .body)
20 | } else {
21 | font = UIFont.systemFont(ofSize: 16)
22 | }
23 |
24 | let button = Style("button")
25 | .underlineStyle(.styleSingle)
26 | .font(font)
27 | .foregroundColor(.black, .normal)
28 | .foregroundColor(.red, .highlighted)
29 |
30 | if #available(iOS 10.0, *) {
31 | attributedLabel.adjustsFontForContentSizeCategory = true
32 | }
33 | attributedLabel.attributedText = "Hello! ".style(tags: button).styleAll(.font(.systemFont(ofSize: 12)))
34 |
35 |
36 | setupTopLabels()
37 | }
38 |
39 | private func setupTopLabels() {
40 | let message = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras eu auctor est. Vestibulum ornare dui ut orci congue placerat. Nunc et tortor vulputate, elementum quam at, tristique nibh. Cras a mollis mauris. Cras non mauris nisi. Ut turpis tellus, pretium sed erat eu, consectetur volutpat nisl. Praesent at bibendum ante. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Quisque ut mauris eu felis venenatis condimentum finibus ac nisi. Nulla id turpis libero. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nullam pulvinar lorem eu metus scelerisque, non lacinia ligula molestie. Vivamus vestibulum sem sit amet pellentesque tristique. Aenean hendrerit mi turpis. Mauris tempus viverra mauris, non accumsan leo aliquet nec. Suspendisse in ipsum ut arcu mollis bibendum."
41 |
42 | let button = Style("button")
43 | .underlineStyle(.styleSingle)
44 | .font(.systemFont(ofSize: 30))
45 | .foregroundColor(.black, .normal)
46 | .foregroundColor(.red, .highlighted)
47 |
48 | issue103Label.numberOfLines = 0
49 | issue103Label.attributedText = message
50 | .style(tags: button)
51 | .styleAll(.font(.systemFont(ofSize: 30)))
52 |
53 | issue103Label.onClick = { label, detection in
54 | print(detection)
55 | }
56 |
57 | pinkLabel.attributedText = "Lorem ipsum dolor sit amet".styleAll(Style())
58 | }
59 |
60 | @IBOutlet private var attributedLabel: AttributedLabel!
61 | @IBOutlet private var issue103Label: AttributedLabel!
62 | @IBOutlet private var pinkLabel: AttributedLabel!
63 | }
64 |
--------------------------------------------------------------------------------
/Demo/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Created by Pavel Sharanda on 21.02.17.
3 | // Copyright © 2017 Pavel Sharanda. All rights reserved.
4 | //
5 |
6 | import UIKit
7 |
8 |
9 | #if swift(>=4.2)
10 | typealias ApplicationLaunchOptionsKey = UIApplication.LaunchOptionsKey
11 | #else
12 | typealias ApplicationLaunchOptionsKey = UIApplicationLaunchOptionsKey
13 | #endif
14 |
15 | @UIApplicationMain
16 | class AppDelegate: UIResponder, UIApplicationDelegate {
17 |
18 | var window: UIWindow?
19 |
20 |
21 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [ApplicationLaunchOptionsKey: Any]?) -> Bool {
22 |
23 | let tbc = UITabBarController()
24 |
25 | let vc1 = SnippetsViewController()
26 | vc1.title = "Snippets"
27 |
28 | let vc2 = AttributedLabelDemoViewController()
29 | vc2.title = "AttributedLabel"
30 |
31 | let vc3 = UIStoryboard(name: "IB", bundle: nil).instantiateViewController(withIdentifier: "ib")
32 | vc3.title = "Storyboard"
33 |
34 | tbc.viewControllers = [UINavigationController(rootViewController: vc1), UINavigationController(rootViewController: vc2), UINavigationController(rootViewController: vc3),]
35 |
36 | window = UIWindow(frame: UIScreen.main.bounds)
37 | window?.rootViewController = tbc
38 | window?.makeKeyAndVisible()
39 |
40 |
41 | return true
42 | }
43 |
44 | func applicationWillResignActive(_ application: UIApplication) {
45 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
46 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
47 | }
48 |
49 | func applicationDidEnterBackground(_ application: UIApplication) {
50 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
51 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
52 | }
53 |
54 | func applicationWillEnterForeground(_ application: UIApplication) {
55 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
56 | }
57 |
58 | func applicationDidBecomeActive(_ application: UIApplication) {
59 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
60 | }
61 |
62 | func applicationWillTerminate(_ application: UIApplication) {
63 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
64 | }
65 |
66 |
67 | }
68 |
69 |
--------------------------------------------------------------------------------
/Atributika.xcodeproj/xcshareddata/xcschemes/Atributika-watchOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
34 |
35 |
45 |
46 |
52 |
53 |
54 |
55 |
56 |
57 |
63 |
64 |
70 |
71 |
72 |
73 |
75 |
76 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/Atributika.xcodeproj/xcshareddata/xcschemes/Atributika-iOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
38 |
39 |
40 |
41 |
43 |
49 |
50 |
51 |
52 |
53 |
63 |
64 |
70 |
71 |
72 |
73 |
79 |
80 |
86 |
87 |
88 |
89 |
91 |
92 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/Atributika.xcodeproj/xcshareddata/xcschemes/Atributika-tvOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
49 |
50 |
51 |
52 |
53 |
54 |
64 |
65 |
71 |
72 |
73 |
74 |
75 |
76 |
82 |
83 |
89 |
90 |
91 |
92 |
94 |
95 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/Atributika.xcodeproj/xcshareddata/xcschemes/Atributika-macOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
49 |
50 |
51 |
52 |
53 |
54 |
64 |
65 |
71 |
72 |
73 |
74 |
75 |
76 |
82 |
83 |
89 |
90 |
91 |
92 |
94 |
95 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/Demo/AttributedLabelDemoViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Created by Pavel Sharanda on 02.03.17.
3 | // Copyright © 2017 Pavel Sharanda. All rights reserved.
4 | //
5 |
6 | import UIKit
7 | import Atributika
8 |
9 | #if swift(>=4.2)
10 | public typealias TableViewCellStyle = UITableViewCell.CellStyle
11 | #else
12 | public typealias TableViewCellStyle = UITableViewCellStyle
13 | #endif
14 |
15 | class AttributedLabelDemoViewController: UIViewController {
16 |
17 | private lazy var tableView: UITableView = {
18 | let tableView = UITableView(frame: CGRect(), style: .plain)
19 |
20 | tableView.delegate = self
21 | tableView.dataSource = self
22 | #if swift(>=4.2)
23 | tableView.rowHeight = UITableView.automaticDimension
24 | #else
25 | tableView.rowHeight = UITableViewAutomaticDimension
26 | #endif
27 | tableView.estimatedRowHeight = 50
28 | return tableView
29 | }()
30 |
31 | private var tweets: [String] = [
32 | "@e2F If only Bradley's arm was longer. Best photo ever. 😊 #oscars https://pic.twitter.com/C9U5NOtGap
Check this link",
33 | "@e2F If only Bradley's arm was longer. Best photo ever. 😊 #oscars😊 https://pic.twitter.com/C9U5NOtGap
Check this link that won't detect click here",
34 | "For every retweet this gets, Pedigree will donate one bowl of dog food to dogs in need! 😊 #tweetforbowls",
35 | "All the love as always. H",
36 | "We got kicked out of a @Delta airplane because I spoke Arabic to my mom on the phone and with my friend slim... WTFFFFFFFF please spread",
37 | "Thank you for everything. My last ask is the same as my first. I'm asking you to believe—not in my ability to create change, but in yours.",
38 | "Four more years.",
39 | "RT or tweet #camilahammersledge for a follow 👽",
40 | "Denny JA: Dengan RT ini, anda ikut memenangkan Jokowi-JK. Pilih pemimpin yg bisa dipercaya (Jokowi) dan pengalaman (JK). #DJoJK",
41 | "Always in my heart @Harry_Styles . Yours sincerely, Louis",
42 | "HELP ME PLEASE. A MAN NEEDS HIS NUGGS https://pbs.twimg.com/media/C8sk8QlUwAAR3qI.jpg",
43 | "Подтверждая номер телефона, вы\nпринимаете «пользовательское соглашение»",
44 | "Here's how a similar one was solved 😄 \nhttps://medium.com/@narcelio/solving-decred-mockingbird-puzzle-5366efeaeed7\n",
45 | "#Hello @World!"
46 | ]
47 |
48 | override func viewDidLoad() {
49 | super.viewDidLoad()
50 | view.addSubview(tableView)
51 | }
52 |
53 | override func viewDidLayoutSubviews() {
54 | super.viewDidLayoutSubviews()
55 | tableView.frame = view.bounds
56 | }
57 | }
58 |
59 | extension AttributedLabelDemoViewController: UITableViewDelegate, UITableViewDataSource {
60 |
61 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
62 | return tweets.count
63 | }
64 |
65 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
66 | let cellId = "CellId"
67 | let cell = (tableView.dequeueReusableCell(withIdentifier: cellId) as? TweetCell) ?? TweetCell(style: .default, reuseIdentifier: cellId)
68 | cell.tweet = tweets[indexPath.row]
69 | return cell
70 | }
71 |
72 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
73 | tableView.deselectRow(at: indexPath, animated: true)
74 | }
75 | }
76 |
77 | class TweetCell: UITableViewCell {
78 | private let tweetLabel = AttributedLabel()
79 |
80 | override init(style: TableViewCellStyle, reuseIdentifier: String?) {
81 | super.init(style: style, reuseIdentifier: reuseIdentifier)
82 |
83 | tweetLabel.onClick = { label, detection in
84 | switch detection.type {
85 | case .hashtag(let tag):
86 | if let url = URL(string: "https://twitter.com/hashtag/\(tag)") {
87 | UIApplication.shared.openURL(url)
88 | }
89 | case .mention(let name):
90 | if let url = URL(string: "https://twitter.com/\(name)") {
91 | UIApplication.shared.openURL(url)
92 | }
93 | case .link(let url):
94 | UIApplication.shared.openURL(url)
95 | case .tag(let tag):
96 | if tag.name == "a", let href = tag.attributes["href"], let url = URL(string: href) {
97 | UIApplication.shared.openURL(url)
98 | }
99 | default:
100 | break
101 | }
102 | }
103 |
104 | contentView.addSubview(tweetLabel)
105 |
106 | let marginGuide = contentView.layoutMarginsGuide
107 |
108 | tweetLabel.translatesAutoresizingMaskIntoConstraints = false
109 | tweetLabel.leadingAnchor.constraint(equalTo: marginGuide.leadingAnchor).isActive = true
110 | tweetLabel.topAnchor.constraint(equalTo: marginGuide.topAnchor).isActive = true
111 | tweetLabel.trailingAnchor.constraint(equalTo: marginGuide.trailingAnchor).isActive = true
112 | tweetLabel.bottomAnchor.constraint(equalTo: marginGuide.bottomAnchor).isActive = true
113 | tweetLabel.numberOfLines = 0
114 | }
115 |
116 | required init?(coder aDecoder: NSCoder) {
117 | fatalError("init(coder:) has not been implemented")
118 | }
119 |
120 | var tweet: String? {
121 | didSet {
122 | let all = Style.font(UIFont.preferredFont(forTextStyle: .body))
123 | let link = Style("a")
124 | .foregroundColor(.blue, .normal)
125 | .foregroundColor(.brown, .highlighted)
126 |
127 | tweetLabel.attributedText = tweet?
128 | .style(tags: link)
129 | .styleHashtags(link)
130 | .styleMentions(link)
131 | .styleLinks(link)
132 | .styleAll(all)
133 | }
134 | }
135 | }
136 |
137 |
138 |
139 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | [](https://travis-ci.org/psharanda/Atributika)
8 | [](https://cocoapods.org/pods/Atributika)
9 | [](https://github.com/Carthage/Carthage)
10 |
11 | `Atributika` is an easy and painless way to build NSAttributedString. It is able to detect HTML-like tags, links, phone numbers, hashtags, any regex or even standard ios data detectors and style them with various attributes like font, color, etc. `Atributika` comes with drop-in label replacement `AttributedLabel` which is able to make any detection clickable
12 |
13 | ## Intro
14 | NSAttributedString is really powerful but still a low level API which requires a lot of work to setup things. It is especially painful if string is template and real content is known only in runtime. If you are dealing with localizations, it is also not easy to build NSAttributedString.
15 |
16 | Oh wait, but you can use Atributika!
17 |
18 | ```swift
19 | let b = Style("b").font(.boldSystemFont(ofSize: 20)).foregroundColor(.red)
20 |
21 | label.attributedText = "Hello World!!!".style(tags: b).attributedString
22 | ```
23 |
24 |
25 |
26 | Yeah, that's much better. Atributika is easy, declarative, flexible and covers all the raw edges for you.
27 |
28 | ## Features
29 |
30 | + NEW! `AttributedLabel` is a drop-in label replacement which **makes detections clickable** and style them dynamically for `normal/highlighted/disabled` states.
31 | + detect and style HTML-like **tags** using custom speedy parser
32 | + detect and style **hashtags** and **mentions** (i.e. #something and @someone)
33 | + detect and style **links** and **phone numbers**
34 | + detect and style regex and NSDataDetector patterns
35 | + style whole string or just particular ranges
36 | + ... and you can chain all above to parse some uber strings!
37 | + clean and expressive api to build styles
38 | + separate set of detection utils, in case you want to use just them
39 | + `+` operator to concatenate NSAttributedString with other attributed or regular strings
40 | + works on iOS, tvOS, watchOS, macOS
41 |
42 | ## Examples
43 |
44 | ### Detect and style tags, provide base style for the rest of string, don't forget about special html symbols
45 |
46 | ```swift
47 | let redColor = UIColor(red:(0xD0 / 255.0), green: (0x02 / 255.0), blue:(0x1B / 255.0), alpha:1.0)
48 | let a = Style("a").foregroundColor(redColor)
49 |
50 | let font = UIFont(name: "AvenirNext-Regular", size: 24)!
51 | let grayColor = UIColor(white: 0x66 / 255.0, alpha: 1)
52 | let all = Style.font(font).foregroundColor(grayColor)
53 |
54 | let str = "<a>tributik</a>"
55 | .style(tags: a)
56 | .styleAll(all)
57 | .attributedString
58 | ```
59 |
60 |
61 |
62 | ### Detect and style hashtags and mentions
63 |
64 | ```swift
65 | let str = "#Hello @World!!!"
66 | .styleHashtags(Style.font(.boldSystemFont(ofSize: 45)))
67 | .styleMentions(Style.foregroundColor(.red))
68 | .attributedString
69 | ```
70 |
71 |
72 |
73 |
74 | ### Detect and style links
75 |
76 | ```swift
77 | let str = "Check this website http://google.com"
78 | .styleLinks(Style.foregroundColor(.blue))
79 | .attributedString
80 | ```
81 |
82 |
83 |
84 | ### Detect and style phone numbers
85 |
86 | ```swift
87 | let str = "Call me (888)555-5512"
88 | .stylePhoneNumbers(Style.foregroundColor(.red))
89 | .attributedString
90 | ```
91 |
92 |
93 |
94 | ### Uber String
95 |
96 | ```swift
97 | let links = Style.foregroundColor(.blue)
98 | let phoneNumbers = Style.backgroundColor(.yellow)
99 | let mentions = Style.font(.italicSystemFont(ofSize: 12)).foregroundColor(.black)
100 | let b = Style("b").font(.boldSystemFont(ofSize: 12))
101 | let u = Style("u").underlineStyle(.styleSingle)
102 | let all = Style.font(.systemFont(ofSize: 12)).foregroundColor(.gray)
103 |
104 | let str = "@all I found really nice framework to manage attributed strings. It is called Atributika. Call me if you want to know more (123)456-7890 #swift #nsattributedstring https://github.com/psharanda/Atributika"
105 | .style(tags: u, b)
106 | .styleMentions(mentions)
107 | .styleHashtags(links)
108 | .styleLinks(links)
109 | .stylePhoneNumbers(phoneNumbers)
110 | .styleAll(all)
111 | .attributedString
112 | ```
113 |
114 |
115 |
116 | ## AttributedText
117 | `Atributika` APIs `styleXXX` produce `AttributedText` which can be converted into `NSAttributedString`. Basically `AttributedText` just contains string, base style and all the detections.
118 |
119 | ## AttributedLabel
120 | `AttributedLabel` is able to display `AttributedText` and makes detections clickable if style contains any attributes for `.highlighted`
121 |
122 | ```swift
123 |
124 | let tweetLabel = AttributedLabel()
125 |
126 | tweetLabel.numberOfLines = 0
127 |
128 | let all = Style.font(.systemFont(ofSize: 20))
129 | let link = Style("a")
130 | .foregroundColor(.blue, .normal)
131 | .foregroundColor(.brown, .highlighted) // <-- detections with this style will be clickable now
132 |
133 | tweetLabel.attributedText = "@e2F If only Bradley's arm was longer. Best photo ever.😊 #oscars https://pic.twitter.com/C9U5NOtGap
Check this link"
134 | .style(tags: link)
135 | .styleHashtags(link)
136 | .styleMentions(link)
137 | .styleLinks(link)
138 | .styleAll(all)
139 |
140 | tweetLabel.onClick = { label, detection in
141 | switch detection.type {
142 | case .hashtag(let tag):
143 | if let url = URL(string: "https://twitter.com/hashtag/\(tag)") {
144 | UIApplication.shared.openURL(url)
145 | }
146 | case .mention(let name):
147 | if let url = URL(string: "https://twitter.com/\(name)") {
148 | UIApplication.shared.openURL(url)
149 | }
150 | case .link(let url):
151 | UIApplication.shared.openURL(url)
152 | case .tag(let tag):
153 | if tag.name == "a", let href = tag.attributes["href"], let url = URL(string: href) {
154 | UIApplication.shared.openURL(url)
155 | }
156 | default:
157 | break
158 | }
159 | }
160 |
161 | view.addSubview(tweetLabel)
162 | ```
163 |
164 |
165 | ## Requirements
166 |
167 | Current version is compatible with:
168 |
169 | * Swift 4.0+ (for Swift 3.2 use `swift-3.2` branch)
170 | * iOS 8.0 or later
171 | * tvOS 9.0 or later
172 | * watchOS 2.0 or later
173 | * macOS 10.10 or later
174 |
175 | Note: `AttributedLabel` works only on iOS
176 |
177 | ## Why does Atributika have one 't' in its name?
178 | Because in Belarusian/Russian we have one letter 't' (атрыбутыка/атрибутика). So basically it is transcription, not real word.
179 |
180 | ## Integration
181 |
182 | ### Carthage
183 |
184 | Add `github "psharanda/Atributika"` to your `Cartfile`
185 |
186 | ### CocoaPods
187 | Atributika is available through [CocoaPods](http://cocoapods.org). To install
188 | it, simply add the following line to your Podfile:
189 |
190 | ```ruby
191 | pod "Atributika"
192 | ```
193 |
194 | ### Manual
195 | 1. Add Atributika to you project as a submodule using `git submodule add https://github.com/psharanda/Atributika.git`
196 | 2. Open the `Atributika` folder & drag `Atributika.xcodeproj` into your project tree
197 | 3. Add `Atributika.framework` to your target's `Link Binary with Libraries` Build Phase
198 | 4. Import Atributika with `import Atributika` and you're ready to go
199 |
200 | ## License
201 |
202 | Atributika is available under the MIT license. See the LICENSE file for more info.
203 |
--------------------------------------------------------------------------------
/Sources/AttributedText.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * Atributika
3 | *
4 | * Copyright (c) 2017 Pavel Sharanda. Licensed under the MIT license, as follows:
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | import Foundation
26 |
27 | public enum DetectionType {
28 | case tag(Tag)
29 | case hashtag(String)
30 | case mention(String)
31 | case regex(String)
32 | case phoneNumber(String)
33 | case link(URL)
34 | case textCheckingType(String, NSTextCheckingResult.CheckingType)
35 | case range
36 | }
37 |
38 | public struct Detection {
39 | public let type: DetectionType
40 | public let style: Style
41 | public let range: Range
42 | let level: Int
43 | }
44 |
45 | public protocol AttributedTextProtocol {
46 | var string: String {get}
47 | var detections: [Detection] {get}
48 | var baseStyle: Style {get}
49 | }
50 |
51 | extension AttributedTextProtocol {
52 |
53 | fileprivate func makeAttributedString(getAttributes: (Style)-> [AttributedStringKey: Any]) -> NSAttributedString {
54 | let attributedString = NSMutableAttributedString(string: string, attributes: getAttributes(baseStyle))
55 |
56 | let sortedDetections = detections.sorted {
57 | $0.level < $1.level
58 | }
59 |
60 | for d in sortedDetections {
61 | let attrs = getAttributes(d.style)
62 | if attrs.count > 0 {
63 | attributedString.addAttributes(attrs, range: NSRange(d.range, in: string))
64 | }
65 | }
66 |
67 | return attributedString
68 | }
69 | }
70 |
71 | public final class AttributedText: AttributedTextProtocol {
72 |
73 | public let string: String
74 | public let detections: [Detection]
75 | public let baseStyle: Style
76 |
77 | init(string: String, detections: [Detection], baseStyle: Style) {
78 | self.string = string
79 | self.detections = detections
80 | self.baseStyle = baseStyle
81 | }
82 |
83 | public lazy private(set) var attributedString: NSAttributedString = {
84 | makeAttributedString { $0.attributes }
85 | }()
86 |
87 | public lazy private(set) var disabledAttributedString: NSAttributedString = {
88 | makeAttributedString { $0.disabledAttributes }
89 | }()
90 | }
91 |
92 | extension AttributedTextProtocol {
93 |
94 | /// style the whole string
95 | public func styleAll(_ style: Style) -> AttributedText {
96 | return AttributedText(string: string, detections: detections, baseStyle: baseStyle.merged(with: style))
97 | }
98 |
99 | /// style things like #xcode #mentions
100 | public func styleHashtags(_ style: Style) -> AttributedText {
101 | let ranges = string.detectHashTags()
102 | let ds = ranges.map { Detection(type: .hashtag(String(string[(string.index($0.lowerBound, offsetBy: 1))..<$0.upperBound])), style: style, range: $0, level: Int.max) }
103 | return AttributedText(string: string, detections: detections + ds, baseStyle: baseStyle)
104 | }
105 |
106 | /// style things like @John @all
107 | public func styleMentions(_ style: Style) -> AttributedText {
108 | let ranges = string.detectMentions()
109 | let ds = ranges.map { Detection(type: .mention(String(string[(string.index($0.lowerBound, offsetBy: 1))..<$0.upperBound])), style: style, range: $0, level: Int.max) }
110 | return AttributedText(string: string, detections: detections + ds, baseStyle: baseStyle)
111 | }
112 |
113 | public func style(regex: String, options: NSRegularExpression.Options = [], style: Style) -> AttributedText {
114 | let ranges = string.detect(regex: regex, options: options)
115 | let ds = ranges.map { Detection(type: .regex(regex), style: style, range: $0, level: Int.max) }
116 | return AttributedText(string: string, detections: detections + ds, baseStyle: baseStyle)
117 | }
118 |
119 | public func style(textCheckingTypes: NSTextCheckingResult.CheckingType, style: Style) -> AttributedText {
120 | let ranges = string.detect(textCheckingTypes: textCheckingTypes)
121 | let ds = ranges.map { Detection(type: .textCheckingType(String(string[$0]), textCheckingTypes), style: style, range: $0, level: Int.max) }
122 | return AttributedText(string: string, detections: detections + ds, baseStyle: baseStyle)
123 | }
124 |
125 | public func stylePhoneNumbers(_ style: Style) -> AttributedText {
126 | let ranges = string.detect(textCheckingTypes: [.phoneNumber])
127 | let ds = ranges.map { Detection(type: .phoneNumber(String(string[$0])), style: style, range: $0, level: Int.max) }
128 | return AttributedText(string: string, detections: detections + ds, baseStyle: baseStyle)
129 | }
130 |
131 | public func styleLinks(_ style: Style) -> AttributedText {
132 | let ranges = string.detect(textCheckingTypes: [.link])
133 |
134 | #if swift(>=4.1)
135 | let ds = ranges.compactMap { range in
136 | URL(string: String(string[range])).map { Detection(type: .link($0), style: style, range: range, level: Int.max) }
137 | }
138 | #else
139 | let ds = ranges.flatMap { range in
140 | URL(string: String(string[range])).map { Detection(type: .link($0), style: style, range: range) }
141 | }
142 | #endif
143 |
144 | return AttributedText(string: string, detections: detections + ds, baseStyle: baseStyle)
145 | }
146 |
147 | public func style(range: Range, style: Style) -> AttributedText {
148 | let d = Detection(type: .range, style: style, range: range, level: Int.max)
149 | return AttributedText(string: string, detections: detections + [d], baseStyle: baseStyle)
150 | }
151 | }
152 |
153 | extension String: AttributedTextProtocol {
154 |
155 | public var string: String {
156 | return self
157 | }
158 |
159 | public var detections: [Detection] {
160 | return []
161 | }
162 |
163 | public var baseStyle: Style {
164 | return Style()
165 | }
166 |
167 | public func style(tags: [Style], transformers: [TagTransformer] = [TagTransformer.brTransformer], tuner: (Style, Tag) -> Style = { s, _ in return s}) -> AttributedText {
168 | let (string, tagsInfo) = detectTags(transformers: transformers)
169 |
170 | var ds: [Detection] = []
171 |
172 | tagsInfo.forEach { t in
173 |
174 | if let style = (tags.first { style in style.name.lowercased() == t.tag.name.lowercased() }) {
175 | ds.append(Detection(type: .tag(t.tag), style: tuner(style, t.tag), range: t.range, level: t.level))
176 | } else {
177 | ds.append(Detection(type: .tag(t.tag), style: Style(), range: t.range, level: t.level))
178 | }
179 | }
180 |
181 | return AttributedText(string: string, detections: ds, baseStyle: baseStyle)
182 | }
183 |
184 | public func style(tags: Style..., transformers: [TagTransformer] = [TagTransformer.brTransformer], tuner: (Style, Tag) -> Style = { s, _ in return s}) -> AttributedText {
185 | return style(tags: tags, transformers: transformers, tuner: tuner)
186 | }
187 |
188 | public var attributedString: NSAttributedString {
189 | return makeAttributedString { $0.attributes }
190 | }
191 |
192 | public var disabledAttributedString: NSAttributedString {
193 | return makeAttributedString { $0.disabledAttributes }
194 | }
195 | }
196 |
197 | extension NSAttributedString: AttributedTextProtocol {
198 |
199 | public var detections: [Detection] {
200 |
201 | var ds: [Detection] = []
202 |
203 | enumerateAttributes(in: NSMakeRange(0, length), options: []) { (attributes, range, _) in
204 | if let range = Range(range, in: self.string) {
205 | ds.append(Detection(type: .range, style: Style("", attributes), range: range, level: Int.max))
206 | }
207 | }
208 |
209 | return ds
210 | }
211 |
212 | public var baseStyle: Style {
213 | return Style()
214 | }
215 |
216 | public var attributedString: NSAttributedString {
217 | return makeAttributedString { $0.attributes }
218 | }
219 |
220 | public var disabledAttributedString: NSAttributedString {
221 | return makeAttributedString { $0.disabledAttributes }
222 | }
223 | }
224 |
--------------------------------------------------------------------------------
/Demo/IB.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/Sources/String+Detection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Created by Pavel Sharanda on 21.02.17.
3 | // Copyright © 2017 psharanda. All rights reserved.
4 | //
5 |
6 | import Foundation
7 |
8 | public struct Tag {
9 | public let name: String
10 | public let attributes: [String: String]
11 | }
12 |
13 | public struct TagInfo {
14 | public let tag: Tag
15 | public let range: Range
16 | public let level: Int
17 | }
18 |
19 | public enum TagType {
20 | case start
21 | case end
22 | }
23 |
24 | public struct TagTransformer {
25 |
26 | public let tagName: String
27 | public let tagType: TagType
28 | public let transform: (Tag) -> String
29 |
30 | public init(tagName: String, tagType: TagType, replaceValue: String) {
31 | self.tagName = tagName
32 | self.tagType = tagType
33 | self.transform = { _ in replaceValue }
34 | }
35 |
36 | public init(tagName: String, tagType: TagType, transform: @escaping (Tag) -> String) {
37 | self.tagName = tagName
38 | self.tagType = tagType
39 | self.transform = transform
40 | }
41 |
42 | public static var brTransformer: TagTransformer {
43 | return TagTransformer(tagName: "br", tagType: .start , replaceValue: "\n")
44 | }
45 | }
46 |
47 | extension String {
48 |
49 | private func parseTag(_ tagString: String, parseAttributes: Bool) -> Tag? {
50 | let tagScanner = Scanner(string: tagString)
51 |
52 | guard let tagName = tagScanner.scanCharacters(from: CharacterSet.alphanumerics) else {
53 | return nil
54 | }
55 |
56 | var attributes = [String: String]()
57 |
58 | while parseAttributes && !tagScanner.isAtEnd {
59 |
60 | guard let name = tagScanner.scanUpTo("=") else {
61 | break
62 | }
63 |
64 | guard tagScanner.scanString("=") != nil else {
65 | break
66 | }
67 |
68 | let startsFromSingleQuote = (tagScanner.scanString("'") != nil)
69 | if !startsFromSingleQuote {
70 | guard tagScanner.scanString("\"") != nil else {
71 | break
72 | }
73 | }
74 |
75 | let quote = startsFromSingleQuote ? "'" : "\""
76 |
77 | let value = tagScanner.scanUpTo(quote) ?? ""
78 |
79 | guard tagScanner.scanString(quote) != nil else {
80 | break
81 | }
82 |
83 | attributes[name] = value.replacingOccurrences(of: """, with: "\"")
84 | }
85 |
86 | return Tag(name: tagName, attributes: attributes)
87 | }
88 |
89 | public func detectTags(transformers: [TagTransformer] = []) -> (string: String, tagsInfo: [TagInfo]) {
90 |
91 | struct TagInfoInternal {
92 | public let tag: Tag
93 | public let rangeStart: Int
94 | public let rangeEnd: Int
95 | public let level: Int
96 | }
97 |
98 | let scanner = Scanner(string: self)
99 | scanner.charactersToBeSkipped = nil
100 | var resultString = String()
101 | var tagsResult = [TagInfoInternal]()
102 | var tagsStack = [(Tag, Int, Int)]()
103 |
104 | while !scanner.isAtEnd {
105 |
106 | if let textString = scanner.scanUpToCharacters(from: CharacterSet(charactersIn: "<&")) {
107 | resultString.append(textString)
108 | } else {
109 | if scanner.scanString("<") != nil {
110 |
111 | if scanner.isAtEnd {
112 | resultString.append("<")
113 | } else {
114 | let scannerString = (scanner.string as NSString)
115 | let nextChar = scannerString.substring(with: NSRange(location: scanner.scanLocation, length: 1))
116 | if CharacterSet.letters.contains(nextChar.unicodeScalars.first!) || (nextChar == "/") {
117 | let tagType = scanner.scanString("/") == nil ? TagType.start : TagType.end
118 | if let tagString = scanner.scanUpTo(">") {
119 |
120 | if scanner.scanString(">") != nil {
121 | if let tag = parseTag(tagString, parseAttributes: tagType == .start ) {
122 |
123 | let resultTextEndIndex = resultString.count
124 |
125 | if let transformer = transformers.first(where: {
126 | $0.tagName.lowercased() == tag.name.lowercased() && $0.tagType == tagType
127 | }) {
128 | resultString.append(transformer.transform(tag))
129 | }
130 |
131 | if tagType == .start {
132 | tagsStack.append((tag, resultTextEndIndex, (tagsStack.last?.2 ?? -1) + 1))
133 | } else {
134 | for (index, (tagInStack, startIndex, level)) in tagsStack.enumerated().reversed() {
135 | if tagInStack.name.lowercased() == tag.name.lowercased() {
136 | tagsResult.append(TagInfoInternal(tag: tagInStack, rangeStart: startIndex, rangeEnd: resultTextEndIndex, level: level))
137 | tagsStack.remove(at: index)
138 | break
139 | }
140 | }
141 | }
142 | }
143 | } else {
144 | resultString.append("<")
145 | resultString.append(tagString)
146 | }
147 | }
148 | } else if nextChar == "!", scannerString.length >= scanner.scanLocation + 3 {
149 | let afterNextChars = scannerString.substring(with: NSRange(location: scanner.scanLocation + 1, length: 2))
150 | if afterNextChars == "--" {
151 | let scanLocation = scanner.scanLocation + 3
152 | _ = scanner.scanUpTo("-->")
153 | if scanner.scanString("-->") == nil {
154 | scanner.scanLocation = scanLocation
155 | resultString.append("world!"
613 |
614 | let (string, tags) = test.detectTags()
615 |
616 |
617 | XCTAssertEqual(string, "Hello world!")
618 | XCTAssertEqual(tags.count, 0)
619 | }
620 |
621 | func testHTMLComment2() {
622 | let test = "Hello world!"
623 |
624 | let (string, tags) = test.detectTags()
625 |
626 |
627 | XCTAssertEqual(string, "Hello world!")
628 | XCTAssertEqual(tags.count, 0)
629 | }
630 |
631 | func testBrokenHTMLComment() {
632 | let test = "Hello world!"
633 |
634 | let (string, tags) = test.detectTags()
635 |
636 |
637 | XCTAssertEqual(string, test)
638 | XCTAssertEqual(tags.count, 0)
639 | }
640 |
641 | func testBrokenHTMLComment2() {
642 | let test = "Hello world!"
673 |
674 | let (string, tags) = test.detectTags()
675 |
676 |
677 | XCTAssertEqual(string, test)
678 | XCTAssertEqual(tags.count, 0)
679 | }
680 |
681 | func testBrokenHTMLComment6() {
682 | let test = "Hello