diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000..2a9926bf --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,18 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 180 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 3 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - neverstale +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.gitignore b/.gitignore index cbf3e072..d6246ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ DerivedData # Carthage/Checkouts Carthage/Build +test_output/* \ No newline at end of file diff --git a/.swift-version b/.swift-version new file mode 100644 index 00000000..819e07a2 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +5.0 diff --git a/.travis.yml b/.travis.yml index b4f9e4a9..3dccd3ab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ language: objective-c - -xcode_project: ActiveLabel.xcodeproj -xcode_scheme: ActiveLabel -osx_image: xcode7 -xcode_sdk: iphonesimulator9.0 +osx_image: xcode12.2 +before_install: +- gem install bundler +- gem update bundler +- gem install fastlane +install: true +script: +- fastlane scan -p "ActiveLabel.xcodeproj" -s "ActiveLabel" diff --git a/ActiveLabel.podspec b/ActiveLabel.podspec index e87e741c..56b7226a 100644 --- a/ActiveLabel.podspec +++ b/ActiveLabel.podspec @@ -1,19 +1,22 @@ Pod::Spec.new do |s| s.name = 'ActiveLabel' - s.version = '0.4.2' + s.version = '1.1.5' s.author = { 'Optonaut' => 'hello@optonaut.co' } s.homepage = 'https://github.com/optonaut/ActiveLabel.swift' s.license = { :type => 'MIT', :file => 'LICENSE' } - s.platform = :ios, '8.0' + s.platform = :ios, '10.0' s.source = { :git => 'https://github.com/optonaut/ActiveLabel.swift.git', :tag => s.version.to_s } - s.summary = 'UILabel drop-in replacement supporting Hashtags (#), Mentions (@) and URLs (http://) written in Swift' + s.summary = 'UILabel drop-in replacement supporting Hashtags (#), Mentions (@), URLs (http://) and custom regex patterns, written in Swift' s.description = <<-DESC - UILabel drop-in replacement supporting Hashtags (#), Mentions (@) and URLs (http://) written in Swift + UILabel drop-in replacement supporting Hashtags (#), Mentions (@), URLs (http://) and custom regex patterns, written in Swift Features - * Up-to-date: Swift 2 (Xcode 7 GM) - * Support for Hashtags, Mentions and Links + * Swift 5.0 (1.1.0+) and 4.2 (1.0.1) + * Default support for **Hashtags, Mentions, Links, Emails** + * Support for custom types via regex + * Ability to enable highlighting only for the desired types + * Ability to trim urls * Super easy to use and lightweight * Works as UILabel drop-in replacement * Well tested and documented diff --git a/ActiveLabel.xcodeproj/project.pbxproj b/ActiveLabel.xcodeproj/project.pbxproj index 88a4c04b..311e4a1c 100644 --- a/ActiveLabel.xcodeproj/project.pbxproj +++ b/ActiveLabel.xcodeproj/project.pbxproj @@ -17,7 +17,11 @@ 8F0249CD1B998A66005D8035 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8F0249CB1B998A66005D8035 /* LaunchScreen.storyboard */; }; 8F0249D31B998C00005D8035 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F0249D21B998C00005D8035 /* ActiveLabel.swift */; }; 8F0249D51B998D21005D8035 /* ActiveType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F0249D41B998D21005D8035 /* ActiveType.swift */; }; + C1D15C791D7C9B610041D119 /* StringTrimExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D15C781D7C9B610041D119 /* StringTrimExtension.swift */; }; + C1D15C7B1D7C9B7E0041D119 /* ActiveBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D15C7A1D7C9B7E0041D119 /* ActiveBuilder.swift */; }; C1E867D61C3D7AEA00FD687A /* RegexParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E867D51C3D7AEA00FD687A /* RegexParser.swift */; }; + E267FA251DB3A34900EEAC4C /* ActiveLabel.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F0249A21B9989B1005D8035 /* ActiveLabel.framework */; }; + E267FA261DB3A34900EEAC4C /* ActiveLabel.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8F0249A21B9989B1005D8035 /* ActiveLabel.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -28,8 +32,29 @@ remoteGlobalIDString = 8F0249A11B9989B1005D8035; remoteInfo = ActiveLabel; }; + E267FA271DB3A34900EEAC4C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8F0249991B9989B1005D8035 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8F0249A11B9989B1005D8035; + remoteInfo = ActiveLabel; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + E267FA291DB3A34900EEAC4C /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + E267FA261DB3A34900EEAC4C /* ActiveLabel.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 8F0249A21B9989B1005D8035 /* ActiveLabel.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ActiveLabel.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8F0249A51B9989B1005D8035 /* ActiveLabel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ActiveLabel.h; sourceTree = ""; }; @@ -46,6 +71,8 @@ 8F0249CE1B998A66005D8035 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8F0249D21B998C00005D8035 /* ActiveLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; 8F0249D41B998D21005D8035 /* ActiveType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveType.swift; sourceTree = ""; }; + C1D15C781D7C9B610041D119 /* StringTrimExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringTrimExtension.swift; sourceTree = ""; }; + C1D15C7A1D7C9B7E0041D119 /* ActiveBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveBuilder.swift; sourceTree = ""; }; C1E867D51C3D7AEA00FD687A /* RegexParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegexParser.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -69,6 +96,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E267FA251DB3A34900EEAC4C /* ActiveLabel.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -99,9 +127,11 @@ isa = PBXGroup; children = ( 8F0249A51B9989B1005D8035 /* ActiveLabel.h */, - 8F0249D21B998C00005D8035 /* ActiveLabel.swift */, 8F0249D41B998D21005D8035 /* ActiveType.swift */, + 8F0249D21B998C00005D8035 /* ActiveLabel.swift */, + C1D15C7A1D7C9B7E0041D119 /* ActiveBuilder.swift */, C1E867D51C3D7AEA00FD687A /* RegexParser.swift */, + C1D15C781D7C9B610041D119 /* StringTrimExtension.swift */, 8F0249A71B9989B1005D8035 /* Info.plist */, ); path = ActiveLabel; @@ -186,10 +216,12 @@ 8F0249BC1B998A66005D8035 /* Sources */, 8F0249BD1B998A66005D8035 /* Frameworks */, 8F0249BE1B998A66005D8035 /* Resources */, + E267FA291DB3A34900EEAC4C /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + E267FA281DB3A34900EEAC4C /* PBXTargetDependency */, ); name = ActiveLabelDemo; productName = ActiveLabelDemo; @@ -203,23 +235,27 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0700; - LastUpgradeCheck = 0700; + LastUpgradeCheck = 1020; ORGANIZATIONNAME = Optonaut; TargetAttributes = { 8F0249A11B9989B1005D8035 = { CreatedOnToolsVersion = 7.0; + LastSwiftMigration = 1020; }; 8F0249AB1B9989B1005D8035 = { CreatedOnToolsVersion = 7.0; + DevelopmentTeam = G9KGF559KJ; + LastSwiftMigration = 1020; }; 8F0249BF1B998A66005D8035 = { CreatedOnToolsVersion = 7.0; + LastSwiftMigration = 1020; }; }; }; buildConfigurationList = 8F02499C1B9989B1005D8035 /* Build configuration list for PBXProject "ActiveLabel" */; compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; + developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, @@ -269,8 +305,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C1D15C791D7C9B610041D119 /* StringTrimExtension.swift in Sources */, 8F0249D31B998C00005D8035 /* ActiveLabel.swift in Sources */, 8F0249D51B998D21005D8035 /* ActiveType.swift in Sources */, + C1D15C7B1D7C9B7E0041D119 /* ActiveBuilder.swift in Sources */, C1E867D61C3D7AEA00FD687A /* RegexParser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -300,6 +338,11 @@ target = 8F0249A11B9989B1005D8035 /* ActiveLabel */; targetProxy = 8F0249AE1B9989B1005D8035 /* PBXContainerItemProxy */; }; + E267FA281DB3A34900EEAC4C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8F0249A11B9989B1005D8035 /* ActiveLabel */; + targetProxy = E267FA271DB3A34900EEAC4C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -326,17 +369,28 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -359,11 +413,12 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -374,17 +429,28 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -401,9 +467,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -415,18 +483,21 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = ActiveLabel/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = optonaut.ActiveLabel; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -434,61 +505,72 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = ActiveLabel/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MARKETING_VERSION = 1.1.5; PRODUCT_BUNDLE_IDENTIFIER = optonaut.ActiveLabel; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; }; name = Release; }; 8F0249BA1B9989B1005D8035 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + DEVELOPMENT_TEAM = G9KGF559KJ; INFOPLIST_FILE = ActiveLabelTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = optonaut.ActiveLabelTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Debug; }; 8F0249BB1B9989B1005D8035 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + DEVELOPMENT_TEAM = G9KGF559KJ; INFOPLIST_FILE = ActiveLabelTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = optonaut.ActiveLabelTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; 8F0249CF1B998A66005D8035 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_FILE = ActiveLabelDemo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = optonaut.ActiveLabelDemo; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Debug; }; 8F0249D01B998A66005D8035 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_FILE = ActiveLabelDemo/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = optonaut.ActiveLabelDemo; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; diff --git a/ActiveLabel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ActiveLabel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ActiveLabel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ActiveLabel.xcodeproj/xcshareddata/xcschemes/ActiveLabel.xcscheme b/ActiveLabel.xcodeproj/xcshareddata/xcschemes/ActiveLabel.xcscheme index b9944f43..5a813f1b 100644 --- a/ActiveLabel.xcodeproj/xcshareddata/xcschemes/ActiveLabel.xcscheme +++ b/ActiveLabel.xcodeproj/xcshareddata/xcschemes/ActiveLabel.xcscheme @@ -1,6 +1,6 @@ Bool) + +struct ActiveBuilder { + + static func createElements(type: ActiveType, from text: String, range: NSRange, filterPredicate: ActiveFilterPredicate?) -> [ElementTuple] { + switch type { + case .mention, .hashtag: + return createElementsIgnoringFirstCharacter(from: text, for: type, range: range, filterPredicate: filterPredicate) + case .url: + return createElements(from: text, for: type, range: range, filterPredicate: filterPredicate) + case .custom: + return createElements(from: text, for: type, range: range, minLength: 1, filterPredicate: filterPredicate) + case .email: + return createElements(from: text, for: type, range: range, filterPredicate: filterPredicate) + } + } + + static func createURLElements(from text: String, range: NSRange, maximumLength: Int?) -> ([ElementTuple], String) { + let type = ActiveType.url + var text = text + let matches = RegexParser.getElements(from: text, with: type.pattern, range: range) + let nsstring = text as NSString + var elements: [ElementTuple] = [] + + for match in matches where match.range.length > 2 { + let word = nsstring.substring(with: match.range) + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + + guard let maxLength = maximumLength, word.count > maxLength else { + let range = maximumLength == nil ? match.range : (text as NSString).range(of: word) + let element = ActiveElement.create(with: type, text: word) + elements.append((range, element, type)) + continue + } + + let trimmedWord = word.trim(to: maxLength) + text = text.replacingOccurrences(of: word, with: trimmedWord) + + let newRange = (text as NSString).range(of: trimmedWord) + let element = ActiveElement.url(original: word, trimmed: trimmedWord) + elements.append((newRange, element, type)) + } + return (elements, text) + } + + private static func createElements(from text: String, + for type: ActiveType, + range: NSRange, + minLength: Int = 2, + filterPredicate: ActiveFilterPredicate?) -> [ElementTuple] { + + let matches = RegexParser.getElements(from: text, with: type.pattern, range: range) + let nsstring = text as NSString + var elements: [ElementTuple] = [] + + for match in matches where match.range.length > minLength { + let word = nsstring.substring(with: match.range) + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if filterPredicate?(word) ?? true { + let element = ActiveElement.create(with: type, text: word) + elements.append((match.range, element, type)) + } + } + return elements + } + + private static func createElementsIgnoringFirstCharacter(from text: String, + for type: ActiveType, + range: NSRange, + filterPredicate: ActiveFilterPredicate?) -> [ElementTuple] { + let matches = RegexParser.getElements(from: text, with: type.pattern, range: range) + let nsstring = text as NSString + var elements: [ElementTuple] = [] + + for match in matches where match.range.length > 2 { + let range = NSRange(location: match.range.location + 1, length: match.range.length - 1) + var word = nsstring.substring(with: range) + if word.hasPrefix("@") { + word.remove(at: word.startIndex) + } + else if word.hasPrefix("#") { + word.remove(at: word.startIndex) + } + + if filterPredicate?(word) ?? true { + let element = ActiveElement.create(with: type, text: word) + elements.append((match.range, element, type)) + } + } + return elements + } +} diff --git a/ActiveLabel/ActiveLabel.swift b/ActiveLabel/ActiveLabel.swift index 2832f6a2..073d4532 100644 --- a/ActiveLabel/ActiveLabel.swift +++ b/ActiveLabel/ActiveLabel.swift @@ -10,101 +10,200 @@ import Foundation import UIKit public protocol ActiveLabelDelegate: class { - func didSelectText(text: String, type: ActiveType) + func didSelect(_ text: String, type: ActiveType) } -@IBDesignable public class ActiveLabel: UILabel { +public typealias ConfigureLinkAttribute = (ActiveType, [NSAttributedString.Key : Any], Bool) -> ([NSAttributedString.Key : Any]) +typealias ElementTuple = (range: NSRange, element: ActiveElement, type: ActiveType) + +@IBDesignable open class ActiveLabel: UILabel { // MARK: - public properties - public weak var delegate: ActiveLabelDelegate? + open weak var delegate: ActiveLabelDelegate? + + open var enabledTypes: [ActiveType] = [.mention, .hashtag, .url] + + open var urlMaximumLength: Int? - @IBInspectable public var mentionColor: UIColor = .blueColor() { + open var configureLinkAttribute: ConfigureLinkAttribute? + + @IBInspectable open var mentionColor: UIColor = .blue { + didSet { updateTextStorage(parseText: false) } + } + @IBInspectable open var mentionSelectedColor: UIColor? { + didSet { updateTextStorage(parseText: false) } + } + @IBInspectable open var hashtagColor: UIColor = .blue { + didSet { updateTextStorage(parseText: false) } + } + @IBInspectable open var hashtagSelectedColor: UIColor? { + didSet { updateTextStorage(parseText: false) } + } + @IBInspectable open var URLColor: UIColor = .blue { didSet { updateTextStorage(parseText: false) } } - @IBInspectable public var mentionSelectedColor: UIColor? { + @IBInspectable open var URLSelectedColor: UIColor? { didSet { updateTextStorage(parseText: false) } } - @IBInspectable public var hashtagColor: UIColor = .blueColor() { + open var customColor: [ActiveType : UIColor] = [:] { didSet { updateTextStorage(parseText: false) } } - @IBInspectable public var hashtagSelectedColor: UIColor? { + open var customSelectedColor: [ActiveType : UIColor] = [:] { didSet { updateTextStorage(parseText: false) } } - @IBInspectable public var URLColor: UIColor = .blueColor() { + @IBInspectable public var lineSpacing: CGFloat = 0 { didSet { updateTextStorage(parseText: false) } } - @IBInspectable public var URLSelectedColor: UIColor? { + @IBInspectable public var minimumLineHeight: CGFloat = 0 { didSet { updateTextStorage(parseText: false) } } - @IBInspectable public var lineSpacing: Float? { + @IBInspectable public var highlightFontName: String? = nil { didSet { updateTextStorage(parseText: false) } } + public var highlightFontSize: CGFloat? = nil { + didSet { updateTextStorage(parseText: false) } + } + + // MARK: - Computed Properties + private var hightlightFont: UIFont? { + guard let highlightFontName = highlightFontName, let highlightFontSize = highlightFontSize else { return nil } + return UIFont(name: highlightFontName, size: highlightFontSize) + } // MARK: - public methods - public func handleMentionTap(handler: (String) -> ()) { + open func handleMentionTap(_ handler: @escaping (String) -> ()) { mentionTapHandler = handler } - public func handleHashtagTap(handler: (String) -> ()) { + open func handleHashtagTap(_ handler: @escaping (String) -> ()) { hashtagTapHandler = handler } - public func handleURLTap(handler: (NSURL) -> ()) { + open func handleURLTap(_ handler: @escaping (URL) -> ()) { urlTapHandler = handler } + open func handleCustomTap(for type: ActiveType, handler: @escaping (String) -> ()) { + customTapHandlers[type] = handler + } + + open func handleEmailTap(_ handler: @escaping (String) -> ()) { + emailTapHandler = handler + } + + open func removeHandle(for type: ActiveType) { + switch type { + case .hashtag: + hashtagTapHandler = nil + case .mention: + mentionTapHandler = nil + case .url: + urlTapHandler = nil + case .custom: + customTapHandlers[type] = nil + case .email: + emailTapHandler = nil + } + } + + open func filterMention(_ predicate: @escaping (String) -> Bool) { + mentionFilterPredicate = predicate + updateTextStorage() + } + + open func filterHashtag(_ predicate: @escaping (String) -> Bool) { + hashtagFilterPredicate = predicate + updateTextStorage() + } + // MARK: - override UILabel properties - override public var text: String? { + override open var text: String? { didSet { updateTextStorage() } } - override public var attributedText: NSAttributedString? { + override open var attributedText: NSAttributedString? { didSet { updateTextStorage() } } - override public var font: UIFont! { + override open var font: UIFont! { didSet { updateTextStorage(parseText: false) } } - override public var textColor: UIColor! { + override open var textColor: UIColor! { didSet { updateTextStorage(parseText: false) } } - override public var textAlignment: NSTextAlignment { + override open var textAlignment: NSTextAlignment { didSet { updateTextStorage(parseText: false)} } + open override var numberOfLines: Int { + didSet { textContainer.maximumNumberOfLines = numberOfLines } + } + + open override var lineBreakMode: NSLineBreakMode { + didSet { textContainer.lineBreakMode = lineBreakMode } + } + // MARK: - init functions override public init(frame: CGRect) { super.init(frame: frame) - + _customizing = false setupLabel() } required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) - + _customizing = false setupLabel() } - public override func drawTextInRect(rect: CGRect) { + open override func awakeFromNib() { + super.awakeFromNib() + updateTextStorage() + } + + open override func drawText(in rect: CGRect) { let range = NSRange(location: 0, length: textStorage.length) textContainer.size = rect.size let newOrigin = textOrigin(inRect: rect) - layoutManager.drawBackgroundForGlyphRange(range, atPoint: newOrigin) - layoutManager.drawGlyphsForGlyphRange(range, atPoint: newOrigin) + layoutManager.drawBackground(forGlyphRange: range, at: newOrigin) + layoutManager.drawGlyphs(forGlyphRange: range, at: newOrigin) + } + + + // MARK: - customzation + @discardableResult + open func customize(_ block: (_ label: ActiveLabel) -> ()) -> ActiveLabel { + _customizing = true + block(self) + _customizing = false + updateTextStorage() + return self } + + // MARK: - Auto layout + + open override var intrinsicContentSize: CGSize { + guard let text = text, !text.isEmpty else { + return .zero + } + textContainer.size = CGSize(width: self.preferredMaxLayoutWidth, height: CGFloat.greatestFiniteMagnitude) + let size = layoutManager.usedRect(for: textContainer) + return CGSize(width: ceil(size.width), height: ceil(size.height)) + } + // MARK: - touch events - func onTouch(touch: UITouch) -> Bool { - let location = touch.locationInView(self) + func onTouch(_ touch: UITouch) -> Bool { + let location = touch.location(in: self) var avoidSuperCall = false switch touch.phase { - case .Began, .Moved: - if let element = elementAtLocation(location) { + case .began, .moved, .regionEntered, .regionMoved: + if let element = element(at: location) { if element.range.location != selectedElement?.range.location || element.range.length != selectedElement?.range.length { updateAttributesWhenSelected(false) selectedElement = element @@ -115,25 +214,29 @@ public protocol ActiveLabelDelegate: class { updateAttributesWhenSelected(false) selectedElement = nil } - case .Ended: + case .ended, .regionExited: guard let selectedElement = selectedElement else { return avoidSuperCall } switch selectedElement.element { - case .Mention(let userHandle): didTapMention(userHandle) - case .Hashtag(let hashtag): didTapHashtag(hashtag) - case .URL(let url): didTapStringURL(url) - case .None: () + case .mention(let userHandle): didTapMention(userHandle) + case .hashtag(let hashtag): didTapHashtag(hashtag) + case .url(let originalURL, _): didTapStringURL(originalURL) + case .custom(let element): didTap(element, for: selectedElement.type) + case .email(let element): didTapStringEmail(element) } - let when = dispatch_time(DISPATCH_TIME_NOW, Int64(0.25 * Double(NSEC_PER_SEC))) - dispatch_after(when, dispatch_get_main_queue()) { + let when = DispatchTime.now() + Double(Int64(0.25 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) + DispatchQueue.main.asyncAfter(deadline: when) { self.updateAttributesWhenSelected(false) self.selectedElement = nil } avoidSuperCall = true - case .Cancelled: + case .cancelled: + updateAttributesWhenSelected(false) selectedElement = nil - case .Stationary: + case .stationary: + break + @unknown default: break } @@ -141,75 +244,103 @@ public protocol ActiveLabelDelegate: class { } // MARK: - private properties - private var mentionTapHandler: ((String) -> ())? - private var hashtagTapHandler: ((String) -> ())? - private var urlTapHandler: ((NSURL) -> ())? - - private var selectedElement: (range: NSRange, element: ActiveElement)? - private var heightCorrection: CGFloat = 0 - private lazy var textStorage = NSTextStorage() - private lazy var layoutManager = NSLayoutManager() - private lazy var textContainer = NSTextContainer() - internal lazy var activeElements: [ActiveType: [(range: NSRange, element: ActiveElement)]] = [ - .Mention: [], - .Hashtag: [], - .URL: [], - ] + fileprivate var _customizing: Bool = true + fileprivate var defaultCustomColor: UIColor = .black + + internal var mentionTapHandler: ((String) -> ())? + internal var hashtagTapHandler: ((String) -> ())? + internal var urlTapHandler: ((URL) -> ())? + internal var emailTapHandler: ((String) -> ())? + internal var customTapHandlers: [ActiveType : ((String) -> ())] = [:] + + fileprivate var mentionFilterPredicate: ((String) -> Bool)? + fileprivate var hashtagFilterPredicate: ((String) -> Bool)? + + fileprivate var selectedElement: ElementTuple? + fileprivate var heightCorrection: CGFloat = 0 + internal lazy var textStorage = NSTextStorage() + fileprivate lazy var layoutManager = NSLayoutManager() + fileprivate lazy var textContainer = NSTextContainer() + lazy var activeElements = [ActiveType: [ElementTuple]]() // MARK: - helper functions - private func setupLabel() { + + fileprivate func setupLabel() { textStorage.addLayoutManager(layoutManager) layoutManager.addTextContainer(textContainer) textContainer.lineFragmentPadding = 0 - userInteractionEnabled = true + textContainer.lineBreakMode = lineBreakMode + textContainer.maximumNumberOfLines = numberOfLines + isUserInteractionEnabled = true } - private func updateTextStorage(parseText parseText: Bool = true) { + fileprivate func updateTextStorage(parseText: Bool = true) { + if _customizing { return } // clean up previous active elements - guard let attributedText = attributedText - where attributedText.length > 0 else { + guard let attributedText = attributedText, attributedText.length > 0 else { + clearActiveElements() + textStorage.setAttributedString(NSAttributedString()) + setNeedsDisplay() return } let mutAttrString = addLineBreak(attributedText) - + if parseText { - for (type, _) in activeElements { - activeElements[type]?.removeAll() - } - parseTextAndExtractActiveElements(mutAttrString) + clearActiveElements() + let newString = parseTextAndExtractActiveElements(mutAttrString) + mutAttrString.mutableString.setString(newString) } - self.addLinkAttribute(mutAttrString) - self.textStorage.setAttributedString(mutAttrString) - self.setNeedsDisplay() + addLinkAttribute(mutAttrString) + textStorage.setAttributedString(mutAttrString) + _customizing = true + text = mutAttrString.string + _customizing = false + setNeedsDisplay() + } + + fileprivate func clearActiveElements() { + selectedElement = nil + for (type, _) in activeElements { + activeElements[type]?.removeAll() + } } - private func textOrigin(inRect rect: CGRect) -> CGPoint { - let usedRect = layoutManager.usedRectForTextContainer(textContainer) + fileprivate func textOrigin(inRect rect: CGRect) -> CGPoint { + let usedRect = layoutManager.usedRect(for: textContainer) heightCorrection = (rect.height - usedRect.height)/2 let glyphOriginY = heightCorrection > 0 ? rect.origin.y + heightCorrection : rect.origin.y return CGPoint(x: rect.origin.x, y: glyphOriginY) } /// add link attribute - private func addLinkAttribute(mutAttrString: NSMutableAttributedString) { + fileprivate func addLinkAttribute(_ mutAttrString: NSMutableAttributedString) { var range = NSRange(location: 0, length: 0) - var attributes = mutAttrString.attributesAtIndex(0, effectiveRange: &range) + var attributes = mutAttrString.attributes(at: 0, effectiveRange: &range) - attributes[NSFontAttributeName] = font! - attributes[NSForegroundColorAttributeName] = textColor + attributes[NSAttributedString.Key.font] = font! + attributes[NSAttributedString.Key.foregroundColor] = textColor mutAttrString.addAttributes(attributes, range: range) - attributes[NSForegroundColorAttributeName] = mentionColor + attributes[NSAttributedString.Key.foregroundColor] = mentionColor for (type, elements) in activeElements { switch type { - case .Mention: attributes[NSForegroundColorAttributeName] = mentionColor - case .Hashtag: attributes[NSForegroundColorAttributeName] = hashtagColor - case .URL: attributes[NSForegroundColorAttributeName] = URLColor - case .None: () + case .mention: attributes[NSAttributedString.Key.foregroundColor] = mentionColor + case .hashtag: attributes[NSAttributedString.Key.foregroundColor] = hashtagColor + case .url: attributes[NSAttributedString.Key.foregroundColor] = URLColor + case .custom: attributes[NSAttributedString.Key.foregroundColor] = customColor[type] ?? defaultCustomColor + case .email: attributes[NSAttributedString.Key.foregroundColor] = URLColor + } + + if let highlightFont = hightlightFont { + attributes[NSAttributedString.Key.font] = highlightFont + } + + if let configureLinkAttribute = configureLinkAttribute { + attributes = configureLinkAttribute(type, attributes, false) } for element in elements { @@ -219,65 +350,92 @@ public protocol ActiveLabelDelegate: class { } /// use regex check all link ranges - private func parseTextAndExtractActiveElements(attrString: NSAttributedString) { - let textString = attrString.string - let textLength = textString.utf16.count - let textRange = NSRange(location: 0, length: textLength) + fileprivate func parseTextAndExtractActiveElements(_ attrString: NSAttributedString) -> String { + var textString = attrString.string + var textLength = textString.utf16.count + var textRange = NSRange(location: 0, length: textLength) - //URLS - let urlElements = ActiveBuilder.createURLElements(fromText: textString, range: textRange) - activeElements[.URL]?.appendContentsOf(urlElements) + if enabledTypes.contains(.url) { + let tuple = ActiveBuilder.createURLElements(from: textString, range: textRange, maximumLength: urlMaximumLength) + let urlElements = tuple.0 + let finalText = tuple.1 + textString = finalText + textLength = textString.utf16.count + textRange = NSRange(location: 0, length: textLength) + activeElements[.url] = urlElements + } - //HASHTAGS - let hashtagElements = ActiveBuilder.createHashtagElements(fromText: textString, range: textRange) - activeElements[.Hashtag]?.appendContentsOf(hashtagElements) + for type in enabledTypes where type != .url { + var filter: ((String) -> Bool)? = nil + if type == .mention { + filter = mentionFilterPredicate + } else if type == .hashtag { + filter = hashtagFilterPredicate + } + let hashtagElements = ActiveBuilder.createElements(type: type, from: textString, range: textRange, filterPredicate: filter) + activeElements[type] = hashtagElements + } - //MENTIONS - let mentionElements = ActiveBuilder.createMentionElements(fromText: textString, range: textRange) - activeElements[.Mention]?.appendContentsOf(mentionElements) + return textString } - + /// add line break mode - private func addLineBreak(attrString: NSAttributedString) -> NSMutableAttributedString { + fileprivate func addLineBreak(_ attrString: NSAttributedString) -> NSMutableAttributedString { let mutAttrString = NSMutableAttributedString(attributedString: attrString) var range = NSRange(location: 0, length: 0) - var attributes = mutAttrString.attributesAtIndex(0, effectiveRange: &range) + var attributes = mutAttrString.attributes(at: 0, effectiveRange: &range) - let paragraphStyle = attributes[NSParagraphStyleAttributeName] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - paragraphStyle.lineBreakMode = NSLineBreakMode.ByWordWrapping + let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = NSLineBreakMode.byWordWrapping paragraphStyle.alignment = textAlignment - if let lineSpacing = lineSpacing { - paragraphStyle.lineSpacing = CGFloat(lineSpacing) - } - - attributes[NSParagraphStyleAttributeName] = paragraphStyle + paragraphStyle.lineSpacing = lineSpacing + paragraphStyle.minimumLineHeight = minimumLineHeight > 0 ? minimumLineHeight: self.font.pointSize * 1.14 + attributes[NSAttributedString.Key.paragraphStyle] = paragraphStyle mutAttrString.setAttributes(attributes, range: range) return mutAttrString } - private func updateAttributesWhenSelected(isSelected: Bool) { + fileprivate func updateAttributesWhenSelected(_ isSelected: Bool) { guard let selectedElement = selectedElement else { return } - var attributes = textStorage.attributesAtIndex(0, effectiveRange: nil) + var attributes = textStorage.attributes(at: 0, effectiveRange: nil) + let type = selectedElement.type + if isSelected { - switch selectedElement.element { - case .Mention(_): attributes[NSForegroundColorAttributeName] = mentionColor - case .Hashtag(_): attributes[NSForegroundColorAttributeName] = hashtagColor - case .URL(_): attributes[NSForegroundColorAttributeName] = URLColor - case .None: () + let selectedColor: UIColor + switch type { + case .mention: selectedColor = mentionSelectedColor ?? mentionColor + case .hashtag: selectedColor = hashtagSelectedColor ?? hashtagColor + case .url: selectedColor = URLSelectedColor ?? URLColor + case .custom: + let possibleSelectedColor = customSelectedColor[selectedElement.type] ?? customColor[selectedElement.type] + selectedColor = possibleSelectedColor ?? defaultCustomColor + case .email: selectedColor = URLSelectedColor ?? URLColor } + attributes[NSAttributedString.Key.foregroundColor] = selectedColor } else { - switch selectedElement.element { - case .Mention(_): attributes[NSForegroundColorAttributeName] = mentionSelectedColor ?? mentionColor - case .Hashtag(_): attributes[NSForegroundColorAttributeName] = hashtagSelectedColor ?? hashtagColor - case .URL(_): attributes[NSForegroundColorAttributeName] = URLSelectedColor ?? URLColor - case .None: () + let unselectedColor: UIColor + switch type { + case .mention: unselectedColor = mentionColor + case .hashtag: unselectedColor = hashtagColor + case .url: unselectedColor = URLColor + case .custom: unselectedColor = customColor[selectedElement.type] ?? defaultCustomColor + case .email: unselectedColor = URLColor } + attributes[NSAttributedString.Key.foregroundColor] = unselectedColor + } + + if let highlightFont = hightlightFont { + attributes[NSAttributedString.Key.font] = highlightFont + } + + if let configureLinkAttribute = configureLinkAttribute { + attributes = configureLinkAttribute(type, attributes, isSelected) } textStorage.addAttributes(attributes, range: selectedElement.range) @@ -285,21 +443,21 @@ public protocol ActiveLabelDelegate: class { setNeedsDisplay() } - private func elementAtLocation(location: CGPoint) -> (range: NSRange, element: ActiveElement)? { + fileprivate func element(at location: CGPoint) -> ElementTuple? { guard textStorage.length > 0 else { return nil } - + var correctLocation = location correctLocation.y -= heightCorrection - let boundingRect = layoutManager.boundingRectForGlyphRange(NSRange(location: 0, length: textStorage.length), inTextContainer: textContainer) + let boundingRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: textStorage.length), in: textContainer) guard boundingRect.contains(correctLocation) else { return nil } - let index = layoutManager.glyphIndexForPoint(correctLocation, inTextContainer: textContainer) + let index = layoutManager.glyphIndex(for: correctLocation, in: textContainer) - for element in activeElements.map({ $0.1 }).flatten() { + for element in activeElements.map({ $0.1 }).joined() { if index >= element.range.location && index <= element.range.location + element.range.length { return element } @@ -310,61 +468,83 @@ public protocol ActiveLabelDelegate: class { //MARK: - Handle UI Responder touches - public override func touchesBegan(touches: Set, withEvent event: UIEvent?) { + open override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + if onTouch(touch) { return } + super.touchesBegan(touches, with: event) + } + + open override func touchesMoved(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } - super.touchesBegan(touches, withEvent: event) + super.touchesMoved(touches, with: event) } - public override func touchesCancelled(touches: Set?, withEvent event: UIEvent?) { - guard let touch = touches?.first else { return } - onTouch(touch) - super.touchesCancelled(touches, withEvent: event) + open override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + _ = onTouch(touch) + super.touchesCancelled(touches, with: event) } - public override func touchesEnded(touches: Set, withEvent event: UIEvent?) { + open override func touchesEnded(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } - super.touchesEnded(touches, withEvent: event) + super.touchesEnded(touches, with: event) } //MARK: - ActiveLabel handler - private func didTapMention(username: String) { + fileprivate func didTapMention(_ username: String) { guard let mentionHandler = mentionTapHandler else { - delegate?.didSelectText(username, type: .Mention) + delegate?.didSelect(username, type: .mention) return } mentionHandler(username) } - private func didTapHashtag(hashtag: String) { + fileprivate func didTapHashtag(_ hashtag: String) { guard let hashtagHandler = hashtagTapHandler else { - delegate?.didSelectText(hashtag, type: .Hashtag) + delegate?.didSelect(hashtag, type: .hashtag) return } hashtagHandler(hashtag) } - private func didTapStringURL(stringURL: String) { - guard let urlHandler = urlTapHandler, let url = NSURL(string: stringURL) else { - delegate?.didSelectText(stringURL, type: .URL) + fileprivate func didTapStringURL(_ stringURL: String) { + guard let urlHandler = urlTapHandler, let url = URL(string: stringURL) else { + delegate?.didSelect(stringURL, type: .url) return } urlHandler(url) } + + fileprivate func didTapStringEmail(_ stringEmail: String) { + guard let emailHandler = emailTapHandler else { + delegate?.didSelect(stringEmail, type: .email) + return + } + emailHandler(stringEmail) + } + + fileprivate func didTap(_ element: String, for type: ActiveType) { + guard let elementHandler = customTapHandlers[type] else { + delegate?.didSelect(element, type: type) + return + } + elementHandler(element) + } } extension ActiveLabel: UIGestureRecognizerDelegate { - public func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true } - public func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOfGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true } - public func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailByGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true } } diff --git a/ActiveLabel/ActiveType.swift b/ActiveLabel/ActiveType.swift index d43b64b2..55ee66fc 100644 --- a/ActiveLabel/ActiveType.swift +++ b/ActiveLabel/ActiveType.swift @@ -9,66 +9,60 @@ import Foundation enum ActiveElement { - case Mention(String) - case Hashtag(String) - case URL(String) - case None + case mention(String) + case hashtag(String) + case email(String) + case url(original: String, trimmed: String) + case custom(String) + + static func create(with activeType: ActiveType, text: String) -> ActiveElement { + switch activeType { + case .mention: return mention(text) + case .hashtag: return hashtag(text) + case .email: return email(text) + case .url: return url(original: text, trimmed: text) + case .custom: return custom(text) + } + } } public enum ActiveType { - case Mention - case Hashtag - case URL - case None -} - -struct ActiveBuilder { + case mention + case hashtag + case url + case email + case custom(pattern: String) - static func createMentionElements(fromText text: String, range: NSRange) -> [(range: NSRange, element: ActiveElement)] { - let mentions = RegexParser.getMentions(fromText: text, range: range) - let nsstring = text as NSString - var elements: [(range: NSRange, element: ActiveElement)] = [] - - for mention in mentions where mention.range.length > 2 { - let range = NSRange(location: mention.range.location + 1, length: mention.range.length - 1) - var word = nsstring.substringWithRange(range) - if word.hasPrefix("@") { - word.removeAtIndex(word.startIndex) - } - let element = ActiveElement.Mention(word) - elements.append((mention.range, element)) + var pattern: String { + switch self { + case .mention: return RegexParser.mentionPattern + case .hashtag: return RegexParser.hashtagPattern + case .url: return RegexParser.urlPattern + case .email: return RegexParser.emailPattern + case .custom(let regex): return regex } - return elements } - - static func createHashtagElements(fromText text: String, range: NSRange) -> [(range: NSRange, element: ActiveElement)] { - let hashtags = RegexParser.getHashtags(fromText: text, range: range) - let nsstring = text as NSString - var elements: [(range: NSRange, element: ActiveElement)] = [] - - for hashtag in hashtags where hashtag.range.length > 2 { - let range = NSRange(location: hashtag.range.location + 1, length: hashtag.range.length - 1) - var word = nsstring.substringWithRange(range) - if word.hasPrefix("#") { - word.removeAtIndex(word.startIndex) - } - let element = ActiveElement.Hashtag(word) - elements.append((hashtag.range, element)) +} + +extension ActiveType: Hashable, Equatable { + public func hash(into hasher: inout Hasher) { + switch self { + case .mention: hasher.combine(-1) + case .hashtag: hasher.combine(-2) + case .url: hasher.combine(-3) + case .email: hasher.combine(-4) + case .custom(let regex): hasher.combine(regex) } - return elements } - - static func createURLElements(fromText text: String, range: NSRange) -> [(range: NSRange, element: ActiveElement)] { - let urls = RegexParser.getURLs(fromText: text, range: range) - let nsstring = text as NSString - var elements: [(range: NSRange, element: ActiveElement)] = [] - - for url in urls where url.range.length > 2 { - let word = nsstring.substringWithRange(url.range) - .stringByTrimmingCharactersInSet(NSCharacterSet.whitespaceAndNewlineCharacterSet()) - let element = ActiveElement.URL(word) - elements.append((url.range, element)) - } - return elements +} + +public func ==(lhs: ActiveType, rhs: ActiveType) -> Bool { + switch (lhs, rhs) { + case (.mention, .mention): return true + case (.hashtag, .hashtag): return true + case (.url, .url): return true + case (.email, .email): return true + case (.custom(let pattern1), .custom(let pattern2)): return pattern1 == pattern2 + default: return false } } diff --git a/ActiveLabel/Info.plist b/ActiveLabel/Info.plist index d3de8eef..ca23c84f 100644 --- a/ActiveLabel/Info.plist +++ b/ActiveLabel/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleSignature ???? CFBundleVersion diff --git a/ActiveLabel/RegexParser.swift b/ActiveLabel/RegexParser.swift index 254a8d22..fe9bd7ab 100644 --- a/ActiveLabel/RegexParser.swift +++ b/ActiveLabel/RegexParser.swift @@ -9,28 +9,29 @@ import Foundation struct RegexParser { - + + static let hashtagPattern = "(?:^|\\s|$)#[\\p{L}0-9_]*" + static let mentionPattern = "(?:^|\\s|$|[.])@[\\p{L}0-9_]*" + static let emailPattern = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" static let urlPattern = "(^|[\\s.:;?\\-\\]<\\(])" + - "((https?://|www.|pic.)[-\\w;/?:@&=+$\\|\\_.!~*\\|'()\\[\\]%#,☺]+[\\w/#](\\(\\))?)" + + "((https?://|www\\.|pic\\.)[-\\w;/?:@&=+$\\|\\_.!~*\\|'()\\[\\]%#,☺]+[\\w/#](\\(\\))?)" + "(?=$|[\\s',\\|\\(\\).:;?\\-\\[\\]>\\)])" - - static let hashtagRegex = try? NSRegularExpression(pattern: "(?:^|\\s|$)#[a-z0-9_]*", options: [.CaseInsensitive]) - static let mentionRegex = try? NSRegularExpression(pattern: "(?:^|\\s|$|[.])@[a-z0-9_]*", options: [.CaseInsensitive]) - static let urlDetector = try? NSRegularExpression(pattern: urlPattern, options: [.CaseInsensitive]) - - static func getMentions(fromText text: String, range: NSRange) -> [NSTextCheckingResult] { - guard let mentionRegex = mentionRegex else { return [] } - return mentionRegex.matchesInString(text, options: [], range: range) - } - - static func getHashtags(fromText text: String, range: NSRange) -> [NSTextCheckingResult] { - guard let hashtagRegex = hashtagRegex else { return [] } - return hashtagRegex.matchesInString(text, options: [], range: range) + + private static var cachedRegularExpressions: [String : NSRegularExpression] = [:] + + static func getElements(from text: String, with pattern: String, range: NSRange) -> [NSTextCheckingResult]{ + guard let elementRegex = regularExpression(for: pattern) else { return [] } + return elementRegex.matches(in: text, options: [], range: range) } - - static func getURLs(fromText text: String, range: NSRange) -> [NSTextCheckingResult] { - guard let urlDetector = urlDetector else { return [] } - return urlDetector.matchesInString(text, options: [], range: range) + + private static func regularExpression(for pattern: String) -> NSRegularExpression? { + if let regex = cachedRegularExpressions[pattern] { + return regex + } else if let createdRegex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) { + cachedRegularExpressions[pattern] = createdRegex + return createdRegex + } else { + return nil + } } - -} \ No newline at end of file +} diff --git a/ActiveLabel/StringTrimExtension.swift b/ActiveLabel/StringTrimExtension.swift new file mode 100644 index 00000000..9da442e9 --- /dev/null +++ b/ActiveLabel/StringTrimExtension.swift @@ -0,0 +1,16 @@ +// +// StringTrimExtension.swift +// ActiveLabel +// +// Created by Pol Quintana on 04/09/16. +// Copyright © 2016 Optonaut. All rights reserved. +// + +import Foundation + +extension String { + + func trim(to maximumCharacters: Int) -> String { + return "\(self[.. Bool { - // Override point for customization after application launch. + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + return true } - func applicationWillResignActive(application: UIApplication) { + func applicationWillResignActive(_ application: UIApplication) { // 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. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. } - func applicationDidEnterBackground(application: UIApplication) { + func applicationDidEnterBackground(_ application: UIApplication) { // 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. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. } - func applicationWillEnterForeground(application: UIApplication) { + func applicationWillEnterForeground(_ application: UIApplication) { // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. } - func applicationDidBecomeActive(application: UIApplication) { + func applicationDidBecomeActive(_ application: UIApplication) { // 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. } - func applicationWillTerminate(application: UIApplication) { + func applicationWillTerminate(_ application: UIApplication) { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } diff --git a/ActiveLabelDemo/ViewController.swift b/ActiveLabelDemo/ViewController.swift index ea6acc24..ff472213 100644 --- a/ActiveLabelDemo/ViewController.swift +++ b/ActiveLabelDemo/ViewController.swift @@ -15,20 +15,57 @@ class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - - label.text = "This is a post with #multiple #hashtags and a @userhandle. Links are also supported like this one: http://optonaut.co." - label.numberOfLines = 0 - label.lineSpacing = 4 - - label.textColor = UIColor(red: 102.0/255, green: 117.0/255, blue: 127.0/255, alpha: 1) - label.hashtagColor = UIColor(red: 85.0/255, green: 172.0/255, blue: 238.0/255, alpha: 1) - label.mentionColor = UIColor(red: 238.0/255, green: 85.0/255, blue: 96.0/255, alpha: 1) - label.URLColor = UIColor(red: 85.0/255, green: 238.0/255, blue: 151.0/255, alpha: 1) - - label.handleMentionTap { self.alert("Mention", message: $0) } - label.handleHashtagTap { self.alert("Hashtag", message: $0) } - label.handleURLTap { self.alert("URL", message: $0.description) } - + + let customType = ActiveType.custom(pattern: "\\sare\\b") //Looks for "are" + let customType2 = ActiveType.custom(pattern: "\\sit\\b") //Looks for "it" + let customType3 = ActiveType.custom(pattern: "\\ssupports\\b") //Looks for "supports" + + label.enabledTypes.append(customType) + label.enabledTypes.append(customType2) + label.enabledTypes.append(customType3) + + label.urlMaximumLength = 31 + + label.customize { label in + label.text = "This is a post with #multiple #hashtags and a @userhandle. Links are also supported like" + + " this one: http://optonaut.co. Now it also supports custom patterns -> are\n\n" + + "Let's trim a long link: \nhttps://twitter.com/twicket_app/status/649678392372121601" + label.numberOfLines = 0 + label.lineSpacing = 4 + + label.textColor = UIColor(red: 102.0/255, green: 117.0/255, blue: 127.0/255, alpha: 1) + label.hashtagColor = UIColor(red: 85.0/255, green: 172.0/255, blue: 238.0/255, alpha: 1) + label.mentionColor = UIColor(red: 238.0/255, green: 85.0/255, blue: 96.0/255, alpha: 1) + label.URLColor = UIColor(red: 85.0/255, green: 238.0/255, blue: 151.0/255, alpha: 1) + label.URLSelectedColor = UIColor(red: 82.0/255, green: 190.0/255, blue: 41.0/255, alpha: 1) + + label.handleMentionTap { self.alert("Mention", message: $0) } + label.handleHashtagTap { self.alert("Hashtag", message: $0) } + label.handleURLTap { self.alert("URL", message: $0.absoluteString) } + + //Custom types + + label.customColor[customType] = UIColor.purple + label.customSelectedColor[customType] = UIColor.green + label.customColor[customType2] = UIColor.magenta + label.customSelectedColor[customType2] = UIColor.green + + label.configureLinkAttribute = { (type, attributes, isSelected) in + var atts = attributes + switch type { + case customType3: + atts[NSAttributedString.Key.font] = isSelected ? UIFont.boldSystemFont(ofSize: 16) : UIFont.boldSystemFont(ofSize: 14) + default: () + } + + return atts + } + + label.handleCustomTap(for: customType) { self.alert("Custom type", message: $0) } + label.handleCustomTap(for: customType2) { self.alert("Custom type", message: $0) } + label.handleCustomTap(for: customType3) { self.alert("Custom type", message: $0) } + } + label.frame = CGRect(x: 20, y: 40, width: view.frame.width - 40, height: 300) view.addSubview(label) @@ -41,10 +78,10 @@ class ViewController: UIViewController { // Dispose of any resources that can be recreated. } - func alert(title: String, message: String) { - let vc = UIAlertController(title: title, message: message, preferredStyle: UIAlertControllerStyle.Alert) - vc.addAction(UIAlertAction(title: "Ok", style: .Cancel, handler: nil)) - presentViewController(vc, animated: true, completion: nil) + func alert(_ title: String, message: String) { + let vc = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) + vc.addAction(UIAlertAction(title: "Ok", style: .cancel, handler: nil)) + present(vc, animated: true, completion: nil) } } diff --git a/ActiveLabelTests/ActiveTypeTests.swift b/ActiveLabelTests/ActiveTypeTests.swift index e70b13c0..55f1da8b 100644 --- a/ActiveLabelTests/ActiveTypeTests.swift +++ b/ActiveLabelTests/ActiveTypeTests.swift @@ -11,12 +11,12 @@ import XCTest extension ActiveElement: Equatable {} -func ==(a: ActiveElement, b: ActiveElement) -> Bool { +public func ==(a: ActiveElement, b: ActiveElement) -> Bool { switch (a, b) { - case (.Mention(let a), .Mention(let b)) where a == b: return true - case (.Hashtag(let a), .Hashtag(let b)) where a == b: return true - case (.URL(let a), .URL(let b)) where a == b: return true - case (.None, .None): return true + case (.mention(let a), .mention(let b)) where a == b: return true + case (.hashtag(let a), .hashtag(let b)) where a == b: return true + case (.url(let a), .url(let b)) where a == b: return true + case (.custom(let a), .custom(let b)) where a == b: return true default: return false } } @@ -24,41 +24,37 @@ func ==(a: ActiveElement, b: ActiveElement) -> Bool { class ActiveTypeTests: XCTestCase { let label = ActiveLabel() + let customEmptyType = ActiveType.custom(pattern: "") var activeElements: [ActiveElement] { - return label.activeElements.flatMap({$0.1.flatMap({$0.element})}) + return label.activeElements.flatMap({$0.1.compactMap({$0.element})}) } - var currentElementString: String { - let currentElement = activeElements.first! + var currentElementString: String? { + guard let currentElement = activeElements.first else { return nil } switch currentElement { - case .Mention(let mention): - return mention - case .Hashtag(let hashtag): - return hashtag - case .URL(let url): - return url - case .None: - return "" + case .mention(let mention): return mention + case .hashtag(let hashtag): return hashtag + case .url(let url, _): return url + case .custom(let element): return element + case .email(let element): return element } } - var currentElementType: ActiveType { - let currentElement = activeElements.first! + var currentElementType: ActiveType? { + guard let currentElement = activeElements.first else { return nil } switch currentElement { - case .Mention: - return .Mention - case .Hashtag: - return .Hashtag - case .URL: - return .URL - case .None: - return .None + case .mention: return .mention + case .hashtag: return .hashtag + case .url: return .url + case .custom: return customEmptyType + case .email: return .email } } override func setUp() { super.setUp() + label.enabledTypes = [.mention, .hashtag, .url] // Put setup code here. This method is called before the invocation of each test method in the class. } @@ -84,42 +80,42 @@ class ActiveTypeTests: XCTestCase { label.text = "@userhandle" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "userhandle") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = "@userhandle." XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "userhandle") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = "@_with_underscores_" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "_with_underscores_") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = " . @userhandle" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "userhandle") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = "@user#hashtag" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "user") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = "@user@mention" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "user") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = ".@userhandle" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "userhandle") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = " .@userhandle" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "userhandle") - XCTAssertEqual(currentElementType, ActiveType.Mention) + XCTAssertEqual(currentElementType, ActiveType.mention) label.text = "word@mention" XCTAssertEqual(activeElements.count, 0) @@ -135,32 +131,32 @@ class ActiveTypeTests: XCTestCase { label.text = "#somehashtag" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "somehashtag") - XCTAssertEqual(currentElementType, ActiveType.Hashtag) + XCTAssertEqual(currentElementType, ActiveType.hashtag) label.text = "#somehashtag." XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "somehashtag") - XCTAssertEqual(currentElementType, ActiveType.Hashtag) + XCTAssertEqual(currentElementType, ActiveType.hashtag) label.text = "#_with_underscores_" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "_with_underscores_") - XCTAssertEqual(currentElementType, ActiveType.Hashtag) + XCTAssertEqual(currentElementType, ActiveType.hashtag) label.text = " . #somehashtag" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "somehashtag") - XCTAssertEqual(currentElementType, ActiveType.Hashtag) + XCTAssertEqual(currentElementType, ActiveType.hashtag) label.text = "#some#hashtag" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "some") - XCTAssertEqual(currentElementType, ActiveType.Hashtag) + XCTAssertEqual(currentElementType, ActiveType.hashtag) label.text = "#some@mention" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "some") - XCTAssertEqual(currentElementType, ActiveType.Hashtag) + XCTAssertEqual(currentElementType, ActiveType.hashtag) label.text = ".#somehashtag" XCTAssertEqual(activeElements.count, 0) @@ -180,30 +176,263 @@ class ActiveTypeTests: XCTestCase { label.text = "http://www.google.com" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "http://www.google.com") - XCTAssertEqual(currentElementType, ActiveType.URL) + XCTAssertEqual(currentElementType, ActiveType.url) label.text = "https://www.google.com" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "https://www.google.com") - XCTAssertEqual(currentElementType, ActiveType.URL) + XCTAssertEqual(currentElementType, ActiveType.url) label.text = "http://www.google.com." XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "http://www.google.com") - XCTAssertEqual(currentElementType, ActiveType.URL) + XCTAssertEqual(currentElementType, ActiveType.url) label.text = "www.google.com" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "www.google.com") - XCTAssertEqual(currentElementType, ActiveType.URL) + XCTAssertEqual(currentElementType, ActiveType.url) label.text = "pic.twitter.com/YUGdEbUx" XCTAssertEqual(activeElements.count, 1) XCTAssertEqual(currentElementString, "pic.twitter.com/YUGdEbUx") - XCTAssertEqual(currentElementType, ActiveType.URL) + XCTAssertEqual(currentElementType, ActiveType.url) label.text = "google.com" XCTAssertEqual(activeElements.count, 0) } + + func testCustomType() { + let newType = ActiveType.custom(pattern: "\\sare\\b") + label.enabledTypes.append(newType) + + label.text = "we are one" + XCTAssertEqual(activeElements.count, 1) + XCTAssertEqual(currentElementString, "are") + XCTAssertEqual(currentElementType, customEmptyType) + + label.text = "are. are" + XCTAssertEqual(activeElements.count, 1) + XCTAssertEqual(currentElementString, "are") + XCTAssertEqual(currentElementType, customEmptyType) + + label.text = "helloare are" + XCTAssertEqual(activeElements.count, 1) + XCTAssertEqual(currentElementString, "are") + XCTAssertEqual(currentElementType, customEmptyType) + + label.text = "google" + XCTAssertEqual(activeElements.count, 0) + } + + func testConfigureLinkAttributes() { + // Customize label + let newType = ActiveType.custom(pattern: "\\sare\\b") + label.customize { label in + label.enabledTypes = [newType] + + // Configure "are" to be system font / bold / 14 + label.configureLinkAttribute = { type, attributes, isSelected in + var atts = attributes + if case newType = type { + atts[NSAttributedString.Key.font] = UIFont.boldSystemFont(ofSize: 14) + } + + return atts + } + label.text = "we are one" + } + + // Find attributed text + let range = (label.text! as NSString).range(of: "are") + let areText = label.textStorage.attributedSubstring(from: range) + + // Enumber after attributes and find our font + var foundCustomAttributedStyling = false + areText.enumerateAttributes(in: NSRange(location: 0, length: areText.length), options: [.longestEffectiveRangeNotRequired], using: { (attributes, range, stop) in + foundCustomAttributedStyling = attributes[NSAttributedString.Key.font] as? UIFont == UIFont.boldSystemFont(ofSize: 14) + }) + + XCTAssertTrue(foundCustomAttributedStyling) + } + + func testRemoveHandleMention() { + label.handleMentionTap({_ in }) + XCTAssertNotNil(label.handleMentionTap) + + label.removeHandle(for: .mention) + XCTAssertNil(label.mentionTapHandler) + } + + func testRemoveHandleHashtag() { + label.handleHashtagTap({_ in }) + XCTAssertNotNil(label.handleHashtagTap) + + label.removeHandle(for: .hashtag) + XCTAssertNil(label.hashtagTapHandler) + } + + func testRemoveHandleURL() { + label.handleURLTap({_ in }) + XCTAssertNotNil(label.handleURLTap) + + label.removeHandle(for: .url) + XCTAssertNil(label.urlTapHandler) + } + func testRemoveHandleCustom() { + let newType1 = ActiveType.custom(pattern: "\\sare1\\b") + let newType2 = ActiveType.custom(pattern: "\\sare2\\b") + + label.handleCustomTap(for: newType1, handler: {_ in }) + label.handleCustomTap(for: newType2, handler: {_ in }) + XCTAssertEqual(label.customTapHandlers.count, 2) + + label.removeHandle(for: newType1) + XCTAssertEqual(label.customTapHandlers.count, 1) + + label.removeHandle(for: newType2) + XCTAssertEqual(label.customTapHandlers.count, 0) + } + + func testFiltering() { + label.text = "@user #tag" + XCTAssertEqual(activeElements.count, 2) + + label.filterMention { $0 != "user" } + XCTAssertEqual(activeElements.count, 1) + + label.filterHashtag { $0 != "tag" } + XCTAssertEqual(activeElements.count, 0) + } + + // test for issue https://github.com/optonaut/ActiveLabel.swift/issues/64 + func testIssue64pic() { + label.text = "picfoo" + XCTAssertEqual(activeElements.count, 0) + } + + // test for issue https://github.com/optonaut/ActiveLabel.swift/issues/64 + func testIssue64www() { + label.text = "wwwbar" + XCTAssertEqual(activeElements.count, 0) + } + + func testOnlyMentionsEnabled() { + label.enabledTypes = [.mention] + + label.text = "@user #hashtag" + XCTAssertEqual(activeElements.count, 1) + XCTAssertEqual(currentElementString, "user") + XCTAssertEqual(currentElementType, ActiveType.mention) + + label.text = "http://www.google.com" + XCTAssertEqual(activeElements.count, 0) + + label.text = "#somehashtag" + XCTAssertEqual(activeElements.count, 0) + + label.text = "@userNumberOne #hashtag http://www.google.com @anotheruser" + XCTAssertEqual(activeElements.count, 2) + XCTAssertEqual(currentElementString, "userNumberOne") + XCTAssertEqual(currentElementType, ActiveType.mention) + } + + func testOnlyHashtagEnabled() { + label.enabledTypes = [.hashtag] + + label.text = "@user #hashtag" + XCTAssertEqual(activeElements.count, 1) + XCTAssertEqual(currentElementString, "hashtag") + XCTAssertEqual(currentElementType, ActiveType.hashtag) + + label.text = "http://www.google.com" + XCTAssertEqual(activeElements.count, 0) + + label.text = "@someuser" + XCTAssertEqual(activeElements.count, 0) + + label.text = "#hashtagNumberOne #hashtag http://www.google.com @anotheruser" + XCTAssertEqual(activeElements.count, 2) + XCTAssertEqual(currentElementString, "hashtagNumberOne") + XCTAssertEqual(currentElementType, ActiveType.hashtag) + } + + func testOnlyURLsEnabled() { + label.enabledTypes = [.url] + + label.text = "http://www.google.com #hello" + XCTAssertEqual(activeElements.count, 1) + XCTAssertEqual(currentElementString, "http://www.google.com") + XCTAssertEqual(currentElementType, ActiveType.url) + + label.text = "@user" + XCTAssertEqual(activeElements.count, 0) + + label.text = "#somehashtag" + XCTAssertEqual(activeElements.count, 0) + + label.text = " http://www.apple.com @userNumberOne #hashtag http://www.google.com @anotheruser" + XCTAssertEqual(activeElements.count, 2) + XCTAssertEqual(currentElementString, "http://www.apple.com") + XCTAssertEqual(currentElementType, ActiveType.url) + } + + func testOnlyCustomEnabled() { + let newType = ActiveType.custom(pattern: "\\sare\\b") + label.enabledTypes = [newType] + + label.text = "http://www.google.com are #hello" + XCTAssertEqual(activeElements.count, 1) + XCTAssertEqual(currentElementString, "are") + XCTAssertEqual(currentElementType, customEmptyType) + + label.text = "@user" + XCTAssertEqual(activeElements.count, 0) + + label.text = "#somehashtag" + XCTAssertEqual(activeElements.count, 0) + + label.text = " http://www.apple.com are @userNumberOne #hashtag http://www.google.com are @anotheruser" + XCTAssertEqual(activeElements.count, 2) + XCTAssertEqual(currentElementString, "are") + XCTAssertEqual(currentElementType, customEmptyType) + } + + func testStringTrimming() { + let text = "Tweet with long url: https://twitter.com/twicket_app/status/649678392372121601 and short url: https://hello.co" + label.urlMaximumLength = 30 + label.text = text + + XCTAssertNotEqual(text.count, label.text!.count) + } + + func testStringTrimmingURLShorterThanLimit() { + let text = "Tweet with short url: https://hello.co" + label.urlMaximumLength = 30 + label.text = text + + XCTAssertEqual(text, label.text!) + } + + func testStringTrimmingURLLongerThanLimit() { + let trimLimit = 30 + let url = "https://twitter.com/twicket_app/status/649678392372121601" + let trimmedURL = url.trim(to: trimLimit) + let text = "Tweet with long url: \(url)" + label.urlMaximumLength = trimLimit + label.text = text + + + XCTAssertNotEqual(text.count, label.text!.count) + + switch activeElements.first! { + case .url(let original, let trimmed): + XCTAssertEqual(original, url) + XCTAssertEqual(trimmed, trimmedURL) + default: + XCTAssert(false) + } + + } } diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..84fde9d5 --- /dev/null +++ b/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version:4.2 +import PackageDescription + +let package = Package( + name: "ActiveLabel", + products: [ + .library(name: "ActiveLabel", targets: ["ActiveLabel"]) + ], + targets: [ + .target( + name: "ActiveLabel", + path: "ActiveLabel" + ) + ] +) diff --git a/README.md b/README.md index cc55e2af..3d9795f3 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,112 @@ # ActiveLabel.swift [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) [![Build Status](https://travis-ci.org/optonaut/ActiveLabel.swift.svg)](https://travis-ci.org/optonaut/ActiveLabel.swift) -UILabel drop-in replacement supporting Hashtags (#), Mentions (@) and URLs (http://) written in Swift +UILabel drop-in replacement supporting Hashtags (#), Mentions (@), URLs (http://), Emails and custom regex patterns, written in Swift ## Features -* Swift 2+ -* Support for **Hashtags, Mentions and Links** +* Swift 5.0 (1.1.0+) and 4.2 (1.0.1) +* Default support for **Hashtags, Mentions, Links, Emails** +* Support for **custom types** via regex +* Ability to enable highlighting only for the desired types +* Ability to trim urls * Super easy to use and lightweight * Works as `UILabel` drop-in replacement * Well tested and documented ![](ActiveLabelDemo/demo.gif) +## Install (iOS 10+) + +### Carthage + +Add the following to your `Cartfile` and follow [these instructions](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) + +```sh +github "optonaut/ActiveLabel.swift" +``` + +### CocoaPods + +CocoaPods 0.36 adds supports for Swift and embedded frameworks. To integrate ActiveLabel into your project add the following to your `Podfile`: + +```ruby +platform :ios, '10.0' +use_frameworks! + +pod 'ActiveLabel' +``` + ## Usage ```swift import ActiveLabel let label = ActiveLabel() - label.numberOfLines = 0 +label.enabledTypes = [.mention, .hashtag, .url, .email] label.text = "This is a post with #hashtags and a @userhandle." -label.textColor = .blackColor() +label.textColor = .black label.handleHashtagTap { hashtag in - print("Success. You just tapped the \(hashtag) hashtag") + print("Success. You just tapped the \(hashtag) hashtag") } ``` +## Custom types + +```swift +let customType = ActiveType.custom(pattern: "\\swith\\b") //Regex that looks for "with" +label.enabledTypes = [.mention, .hashtag, .url, .email, customType] +label.text = "This is a post with #hashtags and a @userhandle." +label.customColor[customType] = UIColor.purple +label.customSelectedColor[customType] = UIColor.green +label.handleCustomTap(for: customType) { element in + print("Custom type tapped: \(element)") +} +``` + +## Enable/disable highlighting + +By default, an ActiveLabel instance has the following configuration + +```swift +label.enabledTypes = [.mention, .hashtag, .url, .email] +``` + +But feel free to enable/disable to fit your requirements + +## Batched customization + +When using ActiveLabel, it is recommended to use the `customize(block:)` method to customize it. The reason is that ActiveLabel is reacting to each property that you set. So if you set 3 properties, the textContainer is refreshed 3 times. + +When using `customize(block:)`, you can group all the customizations on the label, that way ActiveLabel is only going to refresh the textContainer once. + +Example: + +```swift +label.customize { label in + label.text = "This is a post with #multiple #hashtags and a @userhandle." + label.textColor = UIColor(red: 102.0/255, green: 117.0/255, blue: 127.0/255, alpha: 1) + label.hashtagColor = UIColor(red: 85.0/255, green: 172.0/255, blue: 238.0/255, alpha: 1) + label.mentionColor = UIColor(red: 238.0/255, green: 85.0/255, blue: 96.0/255, alpha: 1) + label.URLColor = UIColor(red: 85.0/255, green: 238.0/255, blue: 151.0/255, alpha: 1) + label.handleMentionTap { self.alert("Mention", message: $0) } + label.handleHashtagTap { self.alert("Hashtag", message: $0) } + label.handleURLTap { self.alert("URL", message: $0.absoluteString) } +} +``` + +## Trim long urls + +You have the possiblity to set the maximum lenght a url can have; + +```swift +label.urlMaximumLength = 30 +``` + +From now on, a url that's bigger than that, will be trimmed. + +`https://afancyurl.com/whatever` -> `https://afancyurl.com/wh...` + ## API ##### `mentionColor: UIColor = .blueColor()` @@ -35,6 +115,8 @@ label.handleHashtagTap { hashtag in ##### `hashtagSelectedColor: UIColor?` ##### `URLColor: UIColor = .blueColor()` ##### `URLSelectedColor: UIColor?` +##### `customColor: [ActiveType : UIColor]` +##### `customSelectedColor: [ActiveType : UIColor]` ##### `lineSpacing: Float?` ##### `handleMentionTap: (String) -> ()` @@ -52,28 +134,31 @@ label.handleHashtagTap { hashtag in print("\(hashtag) tapped") } ##### `handleURLTap: (NSURL) -> ()` ```swift -label.handleURLTap { url in UIApplication.sharedApplication().openURL(url) } +label.handleURLTap { url in UIApplication.shared.openURL(url) } ``` -## Install (iOS 8+) +##### `handleEmailTap: (String) -> ()` -### Carthage +```swift +label.handleEmailTap { email in print("\(email) tapped") } +``` -Add the following to your `Cartfile` and follow [these instructions](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) +##### `handleCustomTap(for type: ActiveType, handler: (String) -> ())` -``` -github "optonaut/ActiveLabel.swift" +```swift +label.handleCustomTap(for: customType) { element in print("\(element) tapped") } ``` -### CocoaPods +##### `filterHashtag: (String) -> Bool` -CocoaPods 0.36 adds supports for Swift and embedded frameworks. To integrate ActiveLabel into your project add the following to your `Podfile`: +```swift +label.filterHashtag { hashtag in validHashtags.contains(hashtag) } +``` -```ruby -platform :ios, '8.0' -use_frameworks! +##### `filterMention: (String) -> Bool` -pod 'ActiveLabel' +```swift +label.filterMention { mention in validMentions.contains(mention) } ``` ## Alternatives