├── .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 | [![Language: Swift](https://img.shields.io/badge/language-swift-orange.svg)](https://travis-ci.org/psharanda/Atributika) 8 | [![CocoaPods](https://img.shields.io/cocoapods/p/Atributika.svg?style=plastic)](https://cocoapods.org/pods/Atributika) 9 | [![Carthage](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](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