diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..735d939 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at efremidzel@hotmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..10c2fbb --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing to _Magnetic_ + +The following is a set of guidelines for contributing to _Magnetic_ on GitHub. + +> Above all, thank you for your interest in the project and for taking the time to contribute! 👍 + +## Asking Questions + +We don't use GitHub as a support forum. +For any usage questions that are not specific to the project itself, +please ask on [Stack Overflow](https://stackoverflow.com) instead. +By doing so, you'll be more likely to quickly solve your problem, +and you'll allow anyone else with the same question to find the answer. +This also allows maintainers to focus on improving the project for others. + +## Reporting Other Issues + +A great way to contribute to the project +is to send a detailed issue when you encounter a problem. +We always appreciate a well-written, thorough bug report. + +Check that the project issues database +doesn't already include that problem or suggestion before submitting an issue. +If you find a match, add a quick "+1" or "I have this problem too." +Doing this helps prioritize the most common problems and requests. + +Before submitting a new GitHub issue, please make sure to + +- Check out the [documentation](https://github.com/efremidze/Magnetic). +- Read the usage guide on [the README](https://github.com/efremidze/Magnetic/#usage). +- Search for [existing GitHub issues](https://github.com/efremidze/Magnetic/issues). + +If the above doesn't help, please [submit an issue](https://github.com/efremidze/Magnetic/issues) on GitHub. + +## I want to contribute to _Magnetic_ + +### Prerequisites + +To develop _Magnetic_, you will need to use an Xcode version compatible with the Swift version specified in the [README](https://github.com/efremidze/Magnetic/#requirements). + +### Checking out the repository + +- Click the “Fork” button in the upper right corner of repo +- Clone your fork: + - `git clone https://github.com//Magnetic.git` +- Create a new branch to work on: + - `git checkout -b ` + - A good name for a branch describes the thing you’ll be working on, e.g. `voice-over`, `fix-font-size`, etc. + +That’s it! Now you’re ready to work on _Magnetic_. Open the `Magnetic.xcworkspace` workspace to start coding. + +### Things to keep in mind + +- Please do not change the minimum iOS version +- Always document new public methods and properties + +### Testing your local changes + +Before opening a pull request, please make sure your changes don't break things. + +- The framework and example project should build without warnings +- The example project should run without issues. + +### Submitting the PR + +When the coding is done and you’ve finished testing your changes, you are ready to submit the PR to the [main repo](https://github.com/efremidze/Magnetic). Some best practices are: + +- Use a descriptive title +- Link the issues that are related to your PR in the body + +## Code of Conduct + +Help us keep _Magnetic_ open and inclusive. Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +## License + +This project is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file. + +_These contribution guidelines were adapted from [_fastlane_](https://github.com/fastlane/fastlane) guides._ diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..6893c49 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,18 @@ + + +### New Issue Checklist + + +- [ ] I updated Magnetic to the latest version. +- [ ] I read the [Contribution Guidelines](https://github.com/efremidze/Magnetic/blob/master/.github/CONTRIBUTING.md). +- [ ] I read the [documentation](https://github.com/efremidze/Magnetic). +- [ ] I searched for [existing GitHub issues](https://github.com/efremidze/Magnetic/issues). + +### Issue Description + + + +### Environment + +- **iOS Version**: [INSERT iOS VERSION HERE] +- **Device(s)**: [INSERT DEVICE(S) HERE] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9336275 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + + +### Checklist +- [ ] I've tested my changes. +- [ ] I've read the [Contribution Guidelines](CONTRIBUTING.md). +- [ ] I've updated the documentation if necessary. + +### Motivation and Context + + + + + +### Description + diff --git a/.swift-version b/.swift-version new file mode 100755 index 0000000..5186d07 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +4.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a00c52..f8ad4ad 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Change log +## [Version 2.0.5](https://github.com/efremidze/Magnetic/releases/tag/2.0.5) +Released on 2017-12-13 + +- Custom node path + +## [Version 2.0.4](https://github.com/efremidze/Magnetic/releases/tag/2.0.4) +Released on 2017-12-13 + +- Image bugs fix + +## [Version 2.0.3](https://github.com/efremidze/Magnetic/releases/tag/2.0.3) +Released on 2017-12-10 + +- Image aspect ratio fix + +## [Version 2.0.2](https://github.com/efremidze/Magnetic/releases/tag/2.0.2) +Released on 2017-12-06 + +- Selection fix + +## [Version 2.0.1](https://github.com/efremidze/Magnetic/releases/tag/2.0.1) +Released on 2017-11-21 + +- Bug fix + +## [Version 2.0.0](https://github.com/efremidze/Magnetic/releases/tag/2.0.0) +Released on 2017-09-20 + +- Updated to Swift 4 + +## [Version 1.0.8](https://github.com/efremidze/Magnetic/releases/tag/1.0.8) +Released on 2017-09-05 + +- Fixed radius bug + +## [Version 1.0.7](https://github.com/efremidze/Magnetic/releases/tag/1.0.7) +Released on 2017-07-24 + +- Fixed initializer access + +## [Version 1.0.6](https://github.com/efremidze/Magnetic/releases/tag/1.0.6) +Released on 2017-05-14 + +- Added stroke color + +## [Version 1.0.5](https://github.com/efremidze/Magnetic/releases/tag/1.0.5) +Released on 2017-05-11 + +- Added multiline label + ## [Version 1.0.4](https://github.com/efremidze/Magnetic/releases/tag/1.0.4) Released on 2017-04-06 diff --git a/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Assets.xcassets/AppIcon.appiconset/Contents.json index 1d060ed..d8db8d6 100644 --- a/Example/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -84,6 +84,11 @@ "idiom" : "ipad", "size" : "83.5x83.5", "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" } ], "info" : { diff --git a/Example/Assets.xcassets/costa_rica.imageset/Contents.json b/Example/Assets.xcassets/costa rica.imageset/Contents.json similarity index 100% rename from Example/Assets.xcassets/costa_rica.imageset/Contents.json rename to Example/Assets.xcassets/costa rica.imageset/Contents.json diff --git a/Example/Assets.xcassets/costa_rica.imageset/costa_rica.jpg b/Example/Assets.xcassets/costa rica.imageset/costa_rica.jpg similarity index 100% rename from Example/Assets.xcassets/costa_rica.imageset/costa_rica.jpg rename to Example/Assets.xcassets/costa rica.imageset/costa_rica.jpg diff --git a/Example/Assets.xcassets/dominican_republic.imageset/Contents.json b/Example/Assets.xcassets/dominican republic.imageset/Contents.json similarity index 100% rename from Example/Assets.xcassets/dominican_republic.imageset/Contents.json rename to Example/Assets.xcassets/dominican republic.imageset/Contents.json diff --git a/Example/Assets.xcassets/dominican_republic.imageset/dominican_republic.jpg b/Example/Assets.xcassets/dominican republic.imageset/dominican_republic.jpg similarity index 100% rename from Example/Assets.xcassets/dominican_republic.imageset/dominican_republic.jpg rename to Example/Assets.xcassets/dominican republic.imageset/dominican_republic.jpg diff --git a/Example/Assets.xcassets/el_salvador.imageset/Contents.json b/Example/Assets.xcassets/el salvador.imageset/Contents.json similarity index 100% rename from Example/Assets.xcassets/el_salvador.imageset/Contents.json rename to Example/Assets.xcassets/el salvador.imageset/Contents.json diff --git a/Example/Assets.xcassets/el_salvador.imageset/el_salvador.jpg b/Example/Assets.xcassets/el salvador.imageset/el_salvador.jpg similarity index 100% rename from Example/Assets.xcassets/el_salvador.imageset/el_salvador.jpg rename to Example/Assets.xcassets/el salvador.imageset/el_salvador.jpg diff --git a/Example/Extensions.swift b/Example/Extensions.swift index dd7fd3f..ea9c05f 100755 --- a/Example/Extensions.swift +++ b/Example/Extensions.swift @@ -52,7 +52,7 @@ extension UIColor { extension UIImage { - static let names: [String] = ["argentina", "bolivia", "brazil", "chile", "costa_rica", "cuba", "dominican_republic", "ecuador", "el_salvador", "haiti", "honduras", "mexico", "nicaragua", "panama", "paraguay", "peru", "venezuela"] + static let names: [String] = ["argentina", "bolivia", "brazil", "chile", "costa rica", "cuba", "dominican republic", "ecuador", "el salvador", "haiti", "honduras", "mexico", "nicaragua", "panama", "paraguay", "peru", "venezuela"] } diff --git a/Example/ViewController.swift b/Example/ViewController.swift index 452b644..d68cf06 100755 --- a/Example/ViewController.swift +++ b/Example/ViewController.swift @@ -26,8 +26,8 @@ class ViewController: UIViewController { return magneticView.magnetic } - override func viewDidLoad() { - super.viewDidLoad() + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) for _ in 0..<12 { add(nil) @@ -39,14 +39,18 @@ class ViewController: UIViewController { let color = UIColor.colors.randomItem() let node = Node(text: name.capitalized, image: UIImage(named: name), color: color, radius: 40) magnetic.addChild(node) + + // Image Node: image displayed by default + // let node = ImageNode(text: name.capitalized, image: UIImage(named: name), color: color, radius: 40) + // magnetic.addChild(node) } @IBAction func reset(_ sender: UIControl?) { let speed = magnetic.physicsWorld.speed magnetic.physicsWorld.speed = 0 let sortedNodes = magnetic.children.flatMap { $0 as? Node }.sorted { node, nextNode in - let distance = node.position.distance(from: magnetic.magneticField.position) - let nextDistance = nextNode.position.distance(from: magnetic.magneticField.position) + let distance = node.position.distance(from: magnetic.middleMagneticField.position) + let nextDistance = nextNode.position.distance(from: magnetic.middleMagneticField.position) return distance < nextDistance && node.isSelected } var actions = [SKAction]() @@ -89,3 +93,14 @@ extension ViewController: MagneticDelegate { } } + +// MARK: - ImageNode +class ImageNode: Node { + override var image: UIImage? { + didSet { + sprite.texture = image.map { SKTexture(image: $0) } + } + } + override func selectedAnimation() {} + override func deselectedAnimation() {} +} diff --git a/Example/ViewController2.swift b/Example/ViewController2.swift deleted file mode 100755 index 52fb600..0000000 --- a/Example/ViewController2.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// ViewController.swift -// Example -// -// Created by Lasha Efremidze on 3/8/17. -// Copyright © 2017 efremidze. All rights reserved. -// - -import SpriteKit -import Magnetic - -class ViewController: UIViewController { - - @IBOutlet weak var magneticView: MagneticView! { - didSet { - magnetic.magneticDelegate = self - #if DEBUG - magneticView.showsFPS = true - magneticView.showsDrawCount = true - magneticView.showsQuadCount = true - #endif - } - } - - var magnetic: Magnetic { - return magneticView.magnetic - } - - override func viewDidLoad() { - super.viewDidLoad() - - for _ in 0..<12 { - add(nil) - } - } - - @IBAction func add(_ sender: UIControl?) { - let name = UIImage.names.randomItem() - let color = UIColor.colors.randomItem() - let node = CustomNode(text: name.capitalized, image: UIImage(named: name), color: color, radius: 40) - magnetic.addChild(node) - } - - @IBAction func reset(_ sender: UIControl?) { - let speed = magnetic.physicsWorld.speed - magnetic.physicsWorld.speed = 0 - let sortedNodes = magnetic.children.flatMap { $0 as? Node }.sorted { node, nextNode in - let distance = node.position.distance(from: magnetic.magneticField.position) - let nextDistance = nextNode.position.distance(from: magnetic.magneticField.position) - return distance < nextDistance && node.isSelected - } - var actions = [SKAction]() - for (index, node) in sortedNodes.enumerated() { - node.physicsBody = nil - let action = SKAction.run { [unowned magnetic, unowned node] in - if node.isSelected { - let point = CGPoint(x: magnetic.size.width / 2, y: magnetic.size.height + 40) - let movingXAction = SKAction.moveTo(x: point.x, duration: 0.2) - let movingYAction = SKAction.moveTo(y: point.y, duration: 0.4) - let resize = SKAction.scale(to: 0.3, duration: 0.4) - let throwAction = SKAction.group([movingXAction, movingYAction, resize]) - node.run(throwAction) { [unowned node] in - node.removeFromParent() - } - } else { - node.removeFromParent() - } - } - actions.append(action) - let delay = SKAction.wait(forDuration: TimeInterval(index) * 0.002) - actions.append(delay) - } - magnetic.run(.sequence(actions)) { [unowned magnetic] in - magnetic.physicsWorld.speed = speed - } - } - -} - -// MARK: - MagneticDelegate -extension ViewController: MagneticDelegate { - - func magnetic(_ magnetic: Magnetic, didSelect node: Node) { - print("didSelect -> \(node)") - } - - func magnetic(_ magnetic: Magnetic, didDeselect node: Node) { - print("didDeselect -> \(node)") - } - -} - -class CustomNode: Node { - override var image: UIImage? { - didSet { - guard let image = image else { return } - sprite.texture = SKTexture(image: image) - } - } - override func selectedAnimation() {} - override func deselectedAnimation() {} -} diff --git a/Images/logo.sketch b/Images/logo.sketch new file mode 100644 index 0000000..1d198a2 Binary files /dev/null and b/Images/logo.sketch differ diff --git a/Magnetic.podspec b/Magnetic.podspec index a2be1b7..7a30270 100644 --- a/Magnetic.podspec +++ b/Magnetic.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'Magnetic' - s.version = '1.0.4' + s.version = '2.0.5' s.summary = 'SpriteKit Floating Bubble Picker (inspired by Apple Music)' s.homepage = 'https://github.com/efremidze/Magnetic' s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/Magnetic.xcodeproj/project.pbxproj b/Magnetic.xcodeproj/project.pbxproj index 2942309..4015d25 100755 --- a/Magnetic.xcodeproj/project.pbxproj +++ b/Magnetic.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 71835A711EC44BC100782066 /* SKMultilineLabelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71835A701EC44BC100782066 /* SKMultilineLabelNode.swift */; }; + 71E1B90A1FD7ECEB000E73A6 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 71E1B9071FD7ECEA000E73A6 /* README.md */; }; + 71E1B90B1FD7ECEB000E73A6 /* Magnetic.podspec in Resources */ = {isa = PBXBuildFile; fileRef = 71E1B9081FD7ECEA000E73A6 /* Magnetic.podspec */; }; + 71E1B90C1FD7ECEB000E73A6 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 71E1B9091FD7ECEA000E73A6 /* CHANGELOG.md */; }; 8B103C641E88AFA200CC83E2 /* Magnetic.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B6914BB1E70F6A90007896C /* Magnetic.framework */; }; 8B103C651E88AFA200CC83E2 /* Magnetic.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8B6914BB1E70F6A90007896C /* Magnetic.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 8B170CF71E8B2858005F45AB /* SFDisplay-Black.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B170CE21E8B2858005F45AB /* SFDisplay-Black.otf */; }; @@ -68,6 +72,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 71835A701EC44BC100782066 /* SKMultilineLabelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SKMultilineLabelNode.swift; sourceTree = ""; }; + 71E1B9071FD7ECEA000E73A6 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 71E1B9081FD7ECEA000E73A6 /* Magnetic.podspec */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Magnetic.podspec; sourceTree = ""; }; + 71E1B9091FD7ECEA000E73A6 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 8B170CE21E8B2858005F45AB /* SFDisplay-Black.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SFDisplay-Black.otf"; sourceTree = ""; }; 8B170CE31E8B2858005F45AB /* SFDisplay-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SFDisplay-Bold.otf"; sourceTree = ""; }; 8B170CE41E8B2858005F45AB /* SFDisplay-Heavy.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SFDisplay-Heavy.otf"; sourceTree = ""; }; @@ -156,6 +164,9 @@ 8B6914B11E70F6A90007896C = { isa = PBXGroup; children = ( + 71E1B9071FD7ECEA000E73A6 /* README.md */, + 71E1B9091FD7ECEA000E73A6 /* CHANGELOG.md */, + 71E1B9081FD7ECEA000E73A6 /* Magnetic.podspec */, 8B6914BD1E70F6A90007896C /* Magnetic */, 8B6914CB1E70F6B50007896C /* Example */, 8B6914BC1E70F6A90007896C /* Products */, @@ -180,6 +191,7 @@ 8B6914DC1E70F6CF0007896C /* Magnetic.swift */, 8B170D0C1E8B2F60005F45AB /* MagneticView.swift */, 8B61070B1E876D13000FF664 /* Node.swift */, + 71835A701EC44BC100782066 /* SKMultilineLabelNode.swift */, ); name = Magnetic; path = Sources; @@ -258,18 +270,17 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0820; - LastUpgradeCheck = 0820; + LastUpgradeCheck = 0900; ORGANIZATIONNAME = efremidze; TargetAttributes = { 8B6914BA1E70F6A90007896C = { CreatedOnToolsVersion = 8.2; - DevelopmentTeam = NNRNHY2N8B; - LastSwiftMigration = 0820; + LastSwiftMigration = 0900; ProvisioningStyle = Automatic; }; 8B6914C91E70F6B50007896C = { CreatedOnToolsVersion = 8.2; - DevelopmentTeam = NNRNHY2N8B; + LastSwiftMigration = 0900; ProvisioningStyle = Automatic; }; }; @@ -298,6 +309,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 71E1B90B1FD7ECEB000E73A6 /* Magnetic.podspec in Resources */, + 71E1B90C1FD7ECEB000E73A6 /* CHANGELOG.md in Resources */, + 71E1B90A1FD7ECEB000E73A6 /* README.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -343,6 +357,7 @@ 8B6914DD1E70F6CF0007896C /* Magnetic.swift in Sources */, 8B6107141E8771A5000FF664 /* Extensions.swift in Sources */, 8B61070C1E876D13000FF664 /* Node.swift in Sources */, + 71835A711EC44BC100782066 /* SKMultilineLabelNode.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -395,7 +410,9 @@ 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_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -403,7 +420,11 @@ 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_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; @@ -448,7 +469,9 @@ 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_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -456,7 +479,11 @@ 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_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; @@ -491,7 +518,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = NNRNHY2N8B; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -502,7 +529,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 3.0; + SWIFT_SWIFT3_OBJC_INFERENCE = Off; + SWIFT_VERSION = 4.0; }; name = Debug; }; @@ -512,7 +540,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = NNRNHY2N8B; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -522,7 +550,8 @@ PRODUCT_BUNDLE_IDENTIFIER = com.efremidze.Magnetic; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 3.0; + SWIFT_SWIFT3_OBJC_INFERENCE = Off; + SWIFT_VERSION = 4.0; }; name = Release; }; @@ -531,12 +560,13 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - DEVELOPMENT_TEAM = NNRNHY2N8B; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Example/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.efremidze.Example; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 3.0; + SWIFT_SWIFT3_OBJC_INFERENCE = Off; + SWIFT_VERSION = 4.0; }; name = Debug; }; @@ -545,12 +575,13 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - DEVELOPMENT_TEAM = NNRNHY2N8B; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Example/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.efremidze.Example; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 3.0; + SWIFT_SWIFT3_OBJC_INFERENCE = Off; + SWIFT_VERSION = 4.0; }; name = Release; }; diff --git a/Magnetic.xcodeproj/xcshareddata/xcschemes/Magnetic.xcscheme b/Magnetic.xcodeproj/xcshareddata/xcschemes/Magnetic.xcscheme index 183c470..c267b57 100644 --- a/Magnetic.xcodeproj/xcshareddata/xcschemes/Magnetic.xcscheme +++ b/Magnetic.xcodeproj/xcshareddata/xcschemes/Magnetic.xcscheme @@ -1,6 +1,6 @@ Void) { - // override remove animation +override func removedAnimation(completion: @escaping () -> Void) { + // override removed animation } ``` @@ -116,10 +113,23 @@ func magnetic(_ magnetic: Magnetic, didDeselect node: Node) { } ``` -### TODO +### Customization + +Subclass the Node for customization. + +For example, a node with an image by default: -- Add multiple selection states -- Add long press to delete +```swift +class ImageNode: Node { + override var image: UIImage? { + didSet { + sprite.texture = image.map { SKTexture(image: $0) } + } + } + override func selectedAnimation() {} + override func deselectedAnimation() {} +} +``` ## Installation diff --git a/Sources/Extensions.swift b/Sources/Extensions.swift index a727923..38a630f 100755 --- a/Sources/Extensions.swift +++ b/Sources/Extensions.swift @@ -6,20 +6,38 @@ // Copyright © 2017 efremidze. All rights reserved. // -import Foundation +import SpriteKit extension CGFloat { - static func random(_ lower: CGFloat = 0, _ upper: CGFloat = 1) -> CGFloat { return CGFloat(Float(arc4random()) / Float(UINT32_MAX)) * (upper - lower) + lower } - } extension CGPoint { - func distance(from point: CGPoint) -> CGFloat { return hypot(point.x - x, point.y - y) } - +} + +extension UIImage { + func aspectFill(_ size: CGSize) -> UIImage { + let aspectWidth = size.width / self.size.width + let aspectHeight = size.height / self.size.height + let aspectRatio = max(aspectWidth, aspectHeight) + + var newSize = self.size + newSize.width *= aspectRatio + newSize.height *= aspectRatio + return resize(newSize) + } + func resize(_ size: CGSize) -> UIImage { + var rect = CGRect.zero + rect.size = size + UIGraphicsBeginImageContextWithOptions(size, false, scale) + draw(in: rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image! + } } diff --git a/Sources/Info.plist b/Sources/Info.plist old mode 100644 new mode 100755 index 6a68363..f0c69c4 --- a/Sources/Info.plist +++ b/Sources/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.0.4 + 2.0.5 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass diff --git a/Sources/Magnetic.swift b/Sources/Magnetic.swift index 67f50ed..8ce5025 100755 --- a/Sources/Magnetic.swift +++ b/Sources/Magnetic.swift @@ -17,22 +17,67 @@ open class Magnetic: SKScene { /** The field node that accelerates the nodes. + Please use `middleMagneticField` instad. */ - public lazy var magneticField: SKFieldNode = { [unowned self] in + @available(*, deprecated, message: "Please use middleMagneticField instad.") + public lazy var magneticField: SKFieldNode? = self.middleMagneticField + + /** + The field in the middle that either attracts or repels nodes. Nodes will be repeled when `allowDualMagneticFields` is enabled. + */ + public lazy var middleMagneticField: SKFieldNode = { [unowned self] in let field = SKFieldNode.radialGravityField() - field.region = SKRegion(radius: 2000) - field.minimumRadius = 2000 - field.strength = 500 self.addChild(field) return field }() /** - Controls whether you can select multiple nodes. + The left field node that attracts the nodes. + */ + public lazy var leftMagneticField: SKFieldNode = { [unowned self] in + let field = SKFieldNode.radialGravityField() + self.addChild(field) + return field + }() + + /** + The right field node that attracts the nodes. + */ + public lazy var rightMagneticField: SKFieldNode = { [unowned self] in + let field = SKFieldNode.radialGravityField() + self.addChild(field) + return field + }() + + /** + Allows for two magnetic fields along the x axis. Off by default. + **/ + open var allowDualMagneticFields: Bool = false + + /** + Controls whether you can select multiple nodes. On by default. */ open var allowsMultipleSelection: Bool = true + + /** + Lets the user move individule nodes. Off by default. + **/ + open var allowSingleNodeMovement: Bool = false - var isMoving: Bool = false + /** + How fast the node follows the user's finger. Default is 30. + **/ + open var singleNodeMovementAcceleration: CGFloat = 30 + + /** + Lets the user move all of the nodes at once. On by default. + **/ + open var allowAllNodeMovement: Bool = true + + /** + The amount of distance the user's finger is able to travle before considering it a move event instead of a selection. Default is 5px. + **/ + open var nodeSelectionForgivenessDistance: CGFloat = 5 /** The selected children. @@ -48,19 +93,64 @@ open class Magnetic: SKScene { */ open weak var magneticDelegate: MagneticDelegate? - override open func didMove(to view: SKView) { - super.didMove(to: view) + override open var size: CGSize { + didSet { + configure() + } + } + + override public init(size: CGSize) { + super.init(size: size) - self.backgroundColor = .white - self.scaleMode = .aspectFill - self.physicsWorld.gravity = CGVector(dx: 0, dy: 0) - self.physicsBody = SKPhysicsBody(edgeLoopFrom: { () -> CGRect in + commonInit() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + commonInit() + } + + func commonInit() { + backgroundColor = .white + scaleMode = .aspectFill + configure() + } + + func configure() { + let strength = Float(max(size.width, size.height)) + let radius = strength.squareRoot() * 100 + + physicsWorld.gravity = CGVector(dx: 0, dy: 0) + physicsBody = SKPhysicsBody(edgeLoopFrom: { () -> CGRect in var frame = self.frame - frame.size.width = CGFloat(magneticField.minimumRadius) + frame.size.width = CGFloat(radius) frame.origin.x -= frame.size.width / 2 return frame }()) - magneticField.position = CGPoint(x: size.width / 2, y: size.height / 2) + configureFields() + + } + func configureFields(){ + let middlePosition = CGPoint(x: size.width / 2, y: size.height / 2) + updateField(field: middleMagneticField, position: middlePosition) + if allowDualMagneticFields{ + let deviate = middlePosition.x/2 + let leftPosition = CGPoint(x: middlePosition.x - deviate, y: middlePosition.y) + updateField(field: leftMagneticField, position: leftPosition) + let rightPosition = CGPoint(x: middlePosition.x + deviate, y: middlePosition.y) + updateField(field: rightMagneticField, position: rightPosition) + middleMagneticField.strength = -middleMagneticField.strength + } + } + func updateField(field:SKFieldNode, position:CGPoint){ + let strength = Float(max(size.width, size.height)) + let radius = strength.squareRoot() * 100 + + field.region = SKRegion(radius: radius) + field.minimumRadius = radius + field.strength = strength + field.position = position } override open func addChild(_ node: SKNode) { @@ -73,64 +163,110 @@ open class Magnetic: SKScene { super.addChild(node) } - override open func atPoint(_ p: CGPoint) -> SKNode { - var node = super.atPoint(p) - while true { - if node is Node { - return node - } else if let parent = node.parent { - node = parent - } else { - break - } - } - return node - } - } +var movingNode: Node? = nil +var initialTouchLocation: CGPoint? = nil +var initialTouchStartedOnNode: Bool = false +var movingNodeTimer: Timer? = nil + extension Magnetic { - override open func touchesMoved(_ touches: Set, with event: UIEvent?) { if let touch = touches.first { - let location = touch.location(in: self) - let previous = touch.previousLocation(in: self) - - if location.distance(from: previous) == 0 { return } - - isMoving = true - - let x = location.x - previous.x - let y = location.y - previous.y - - for node in children { - let distance = node.position.distance(from: location) - let acceleration: CGFloat = 3 * pow(distance, 1/2) - let direction = CGVector(dx: x * acceleration, dy: y * acceleration) - node.physicsBody?.applyForce(direction) + let point = touch.location(in: self) + if initialTouchLocation == nil{ + initialTouchLocation = point + + if allowSingleNodeMovement{ + movingNode = node(at: point) + } + initialTouchStartedOnNode = movingNode != nil + } + if allowSingleNodeMovement && initialTouchStartedOnNode, let node = movingNode{ + moveNode(node, to: point) + setReacurringMoveTimer(for: node, to: point) + } + else if allowAllNodeMovement{ + moveAllNodes(touchLocation: point, previousTouchLocation: touch.previousLocation(in: self)) } } } override open func touchesEnded(_ touches: Set, with event: UIEvent?) { - if !isMoving, let point = touches.first?.location(in: self), let node = atPoint(point) as? Node { - if node.isSelected { - node.isSelected = false - magneticDelegate?.magnetic(self, didDeselect: node) - } else { - if !allowsMultipleSelection, let selectedNode = selectedChildren.first { - selectedNode.isSelected = false - magneticDelegate?.magnetic(self, didDeselect: selectedNode) + if let touch = touches.first{ + let point = touch.location(in: self) + let initialLocation = initialTouchLocation ?? point + let shouldAllowSelection = initialLocation.distance(from: point) < nodeSelectionForgivenessDistance + + if shouldAllowSelection, + let node = node(at: point) + { + if node.isSelected { + node.isSelected = false + magneticDelegate?.magnetic(self, didDeselect: node) + } else { + if !allowsMultipleSelection, let selectedNode = selectedChildren.first { + selectedNode.isSelected = false + magneticDelegate?.magnetic(self, didDeselect: selectedNode) + } + node.isSelected = true + magneticDelegate?.magnetic(self, didSelect: node) } - node.isSelected = true - magneticDelegate?.magnetic(self, didSelect: node) } } - isMoving = false + resetTouchMovedState() } - override open func touchesCancelled(_ touches: Set, with event: UIEvent?) { - isMoving = false + resetTouchMovedState() + } + + /** + Need to do this otherwise the node floats back to the center when the user is not moving their finger + **/ + func setReacurringMoveTimer(for node:SKNode, to touchLocation:CGPoint){ + if movingNodeTimer != nil{ + movingNodeTimer?.invalidate() + } + movingNodeTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, + selector: #selector(self.keepNodeStill(for:)), + userInfo: ["touchLocation":touchLocation, "node": node], + repeats: true) } + func moveAllNodes(touchLocation: CGPoint, previousTouchLocation: CGPoint){ + if touchLocation.distance(from: previousTouchLocation) == 0 { return } + + let x = touchLocation.x - previousTouchLocation.x + let y = touchLocation.y - previousTouchLocation.y + + for node in children { + let distance = node.position.distance(from: touchLocation) + let acceleration: CGFloat = 3 * pow(distance, 1/2) + let direction = CGVector(dx: x * acceleration, dy: y * acceleration) + node.physicsBody?.applyForce(direction) + } + } + + func node(at point: CGPoint)-> Node?{ + return nodes(at: point).flatMap({ $0 as? Node }).filter({ $0.path!.contains(convert(point, to: $0)) }).first + } + + func moveNode(_ node:SKNode, to touchLocation:CGPoint){ + let convertedTapLocation = convert(touchLocation, to: node) + let direction = CGVector(dx: convertedTapLocation.x * singleNodeMovementAcceleration, dy: convertedTapLocation.y * singleNodeMovementAcceleration) + node.physicsBody?.applyForce(direction) + } + + @objc func keepNodeStill(for timer: Timer){ + let params = timer.userInfo as! [String:Any?] + moveNode(params["node"] as! SKNode, to: params["touchLocation"] as! CGPoint) + } + + func resetTouchMovedState(){ + movingNode = nil + movingNodeTimer?.invalidate() + movingNodeTimer = nil + initialTouchLocation = nil + initialTouchStartedOnNode = false + } } diff --git a/Sources/MagneticView.swift b/Sources/MagneticView.swift index 98c263d..3087a9f 100755 --- a/Sources/MagneticView.swift +++ b/Sources/MagneticView.swift @@ -10,6 +10,7 @@ import SpriteKit public class MagneticView: SKView { + @objc public lazy var magnetic: Magnetic = { [unowned self] in let scene = Magnetic(size: self.bounds.size) self.presentScene(scene) @@ -31,5 +32,11 @@ public class MagneticView: SKView { func commonInit() { _ = magnetic } + + public override func layoutSubviews() { + super.layoutSubviews() + + magnetic.size = bounds.size + } } diff --git a/Sources/Node.swift b/Sources/Node.swift index 26100db..dd95a35 100755 --- a/Sources/Node.swift +++ b/Sources/Node.swift @@ -8,34 +8,17 @@ import SpriteKit -open class Node: SKShapeNode { +open class Node: MaskNode { - lazy var mask: SKCropNode = { [unowned self] in - let node = SKCropNode() - node.maskNode = { - let node = SKShapeNode(circleOfRadius: self.frame.width / 2) - node.fillColor = .white - node.strokeColor = .clear - return node - }() - self.addChild(node) - _ = self.maskOverlay // Masking creates aliasing. This masks the aliasing. - return node - }() - - lazy var maskOverlay: SKShapeNode = { [unowned self] in - let node = SKShapeNode(circleOfRadius: self.frame.width / 2) - node.fillColor = .clear - node.strokeColor = self.strokeColor - self.addChild(node) - return node - }() - - public lazy var label: SKLabelNode = { [unowned self] in - let label = SKLabelNode(fontNamed: "Avenir-Black") + public lazy var label: SKMultilineLabelNode = { [unowned self] in + let label = SKMultilineLabelNode() + label.fontName = "Avenir-Black" label.fontSize = 12 + label.fontColor = .white label.verticalAlignmentMode = .center - self.mask.addChild(label) + label.width = self.frame.width + label.separator = " " + self.addChild(label) return label }() @@ -60,8 +43,10 @@ open class Node: SKShapeNode { */ open var image: UIImage? { didSet { - guard let image = image else { return } - texture = SKTexture(image: image) +// let url = URL(string: "https://picsum.photos/1200/600")! +// let image = UIImage(data: try! Data(contentsOf: url)) + texture = image.map { SKTexture(image: $0.aspectFill(self.frame.size)) } + sprite.size = texture?.size() ?? self.frame.size } } @@ -75,7 +60,13 @@ open class Node: SKShapeNode { set { sprite.color = newValue } } - private(set) var texture: SKTexture! + override open var strokeColor: UIColor { + didSet { + maskOverlay.strokeColor = strokeColor + } + } + + private(set) var texture: SKTexture? /** The selection state of the node. @@ -92,22 +83,22 @@ open class Node: SKShapeNode { } /** - Creates a circular node object. + Creates a node object. - Parameters: - text: The text of the node. - image: The image of the node. - color: The color of the node. - - radius: The radius of the circle. + - radius: The radius of the node. + - path: The path of the node. - Returns: A new node. */ - public convenience init(text: String?, image: UIImage?, color: UIColor, radius: CGFloat) { - self.init() - self.init(circleOfRadius: radius) + public init(text: String?, image: UIImage?, color: UIColor, radius: CGFloat, path: CGPath? = nil) { + super.init(path: path ?? SKShapeNode(circleOfRadius: radius).path!) self.physicsBody = { - let body = SKPhysicsBody(circleOfRadius: radius + 2) + let body = SKPhysicsBody(circleOfRadius: radius + 2) // SKPhysicsBody(polygonFrom: path) body.allowsRotation = false body.friction = 0 body.linearDamping = 3 @@ -120,6 +111,10 @@ open class Node: SKShapeNode { configure(text: text, image: image, color: color) } + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + open func configure(text: String?, image: UIImage?, color: UIColor) { self.text = text self.image = image @@ -137,7 +132,9 @@ open class Node: SKShapeNode { */ open func selectedAnimation() { run(.scale(to: 4/3, duration: 0.2)) - sprite.run(.setTexture(texture)) + if let texture = texture { + sprite.run(.setTexture(texture)) + } } /** @@ -160,3 +157,33 @@ open class Node: SKShapeNode { } } + +open class MaskNode: SKShapeNode { + + let mask: SKCropNode + let maskOverlay: SKShapeNode + + public init(path: CGPath) { + mask = SKCropNode() + mask.maskNode = { + let node = SKShapeNode(path: path) + node.fillColor = .white + node.strokeColor = .clear + return node + }() + + maskOverlay = SKShapeNode(path: path) + maskOverlay.fillColor = .clear + + super.init() + self.path = path + + self.addChild(mask) + self.addChild(maskOverlay) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Sources/SKMultilineLabelNode.swift b/Sources/SKMultilineLabelNode.swift new file mode 100644 index 0000000..7ad3274 --- /dev/null +++ b/Sources/SKMultilineLabelNode.swift @@ -0,0 +1,87 @@ +// +// SKMultilineLabelNode.swift +// Magnetic +// +// Created by Lasha Efremidze on 5/11/17. +// Copyright © 2017 efremidze. All rights reserved. +// + +import SpriteKit + +open class SKMultilineLabelNode: SKNode { + + open var text: String? { didSet { update() } } + + open var fontName: String? { didSet { update() } } + open var fontSize: CGFloat = 32 { didSet { update() } } + open var fontColor: UIColor? { didSet { update() } } + + open var separator: String? { didSet { update() } } + + open var verticalAlignmentMode: SKLabelVerticalAlignmentMode = .baseline { didSet { update() } } + open var horizontalAlignmentMode: SKLabelHorizontalAlignmentMode = .center { didSet { update() } } + + open var lineHeight: CGFloat? { didSet { update() } } + + open var width: CGFloat! { didSet { update() } } + + func update() { + self.removeAllChildren() + + guard let text = text else { return } + + var stack = Stack() + var sizingLabel = makeSizingLabel() + let words = separator.map { text.components(separatedBy: $0) } ?? text.map { String($0) } + for word in words { + sizingLabel.text += word + if sizingLabel.frame.width > width { + stack.add(toStack: word) + sizingLabel = makeSizingLabel() + } else { + stack.add(toCurrent: word) + } + } + + let lines = stack.values.map { $0.joined(separator: separator ?? "") } + for (index, line) in lines.enumerated() { + let label = SKLabelNode(fontNamed: fontName) + label.text = line + label.fontSize = fontSize + label.fontColor = fontColor + label.verticalAlignmentMode = verticalAlignmentMode + label.horizontalAlignmentMode = horizontalAlignmentMode + let y = (CGFloat(index) - (CGFloat(lines.count) / 2) + 0.5) * -(lineHeight ?? fontSize) + label.position = CGPoint(x: 0, y: y) + self.addChild(label) + } + } + + private func makeSizingLabel() -> SKLabelNode { + let label = SKLabelNode(fontNamed: fontName) + label.fontSize = fontSize + return label + } + +} + +private struct Stack { + typealias T = (stack: [[U]], current: [U]) + private var value: T + var values: [[U]] { + return value.stack + [value.current] + } + init() { + self.value = (stack: [], current: []) + } + mutating func add(toStack element: U) { + self.value = (stack: value.stack + [value.current], current: [element]) + } + mutating func add(toCurrent element: U) { + self.value = (stack: value.stack, current: value.current + [element]) + } +} + +private func +=(lhs: inout String?, rhs: String) { + lhs = (lhs ?? "") + rhs +}