diff --git a/Package.swift b/Package.swift index 155830e..3bee605 100644 --- a/Package.swift +++ b/Package.swift @@ -5,24 +5,14 @@ import PackageDescription let package = Package( name: "LocalConsole", - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "LocalConsole", - targets: ["LocalConsole"]), + platforms: [ + .iOS(.v14), ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + products: [ + .library( + name: "LocalConsole", targets: ["LocalConsole"]), ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "LocalConsole", - dependencies: []), - .testTarget( - name: "LocalConsoleTests", - dependencies: ["LocalConsole"]), + .target(name: "LocalConsole", dependencies: []) ] ) diff --git a/README.md b/README.md index 19247ff..068170d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# LocalConsole +# **LocalConsole** A description of this package. diff --git a/Sources/LocalConsole/BorderManager.swift b/Sources/LocalConsole/BorderManager.swift new file mode 100644 index 0000000..3a726ad --- /dev/null +++ b/Sources/LocalConsole/BorderManager.swift @@ -0,0 +1,68 @@ +// +// BorderManager.swift +// +// Created by Duraid Abdul. +// Copyright © 2021 Duraid Abdul. All rights reserved. +// + +#if canImport(UIKit) + +import UIKit + +/// This class handles enabling and disabling debug borders on a specified view. +class BorderManager { + weak var layer: CALayer? + + // Debug configuration defined. + static let outlineWidth = 1 - 1 / UIScreen.main.scale + let outlineColor: CGColor + + // Previous configuration cache. + var cachedWidth: CGFloat? + var cachedColor: CGColor? + + init(view: UIView) { + layer = view.layer + + // Different colors for different UIView types. + if "\(view.classForCoder)".contains("UIImageView") { + outlineColor = UIColor.systemGreen.withAlphaComponent(0.85).cgColor + } else if "\(view.classForCoder)".contains("UILabel") { + outlineColor = UIColor.systemBlue.withAlphaComponent(0.85).cgColor + } else if "\(view.classForCoder)".contains("UIVisualEffectView") { + outlineColor = UIColor.systemIndigo.withAlphaComponent(0.85).cgColor + } else { + outlineColor = UIColor.systemYellow.withAlphaComponent(0.85).cgColor + } + } + + // Activates debug borders. + func activate() { + cachedWidth = layer?.borderWidth + cachedColor = layer?.borderColor + + layer?.borderWidth = Self.outlineWidth + layer?.borderColor = outlineColor + } + + // Deactivates debug borders, restoring previous border properties. + func deactivate() { + + guard let cachedWidth = cachedWidth, let cachedColor = cachedColor else { + layer?.borderWidth = 0.0 + layer?.borderColor = UIColor.clear.cgColor + return + } + + // If the border width has changed since it was outlined, refrain from reverting it to the previous width. + if layer?.borderWidth == Self.outlineWidth { + layer?.borderWidth = cachedWidth + } + // If the border color has changed since it was outlined, refrain from reverting it to the previous color. + if layer?.borderColor == outlineColor { + layer?.borderColor = cachedColor + } + } +} + +#endif diff --git a/Sources/LocalConsole/Console.swift b/Sources/LocalConsole/Console.swift new file mode 100644 index 0000000..bef0b98 --- /dev/null +++ b/Sources/LocalConsole/Console.swift @@ -0,0 +1,449 @@ +// +// LocalConsole.swift +// +// Created by Duraid Abdul. +// Copyright © 2021 Duraid Abdul. All rights reserved. +// + +#if canImport(UIKit) + +import UIKit + +var GLOBAL_DEBUG_BORDERS = false +var GLOBAL_BORDER_TRACKERS: [BorderManager] = [] + +public class Console: NSObject, UIGestureRecognizerDelegate { + + public static let shared = Console() + + let consoleSize = CGSize(width: 208, height: 116) + + // Strong reference needed to keep the window alive. + var consoleWindow: ConsoleWindow? + + // The console needs a view controller to display context menus. + let viewController = UIViewController() + lazy var consoleView = viewController.view! + + let consoleTextView = UITextView() + + var menuButton: UIButton! + + var scrollLocked = true + + let feedbackGenerator = UISelectionFeedbackGenerator() + + lazy var possibleEndpoints = [CGPoint(x: consoleSize.width / 2 + 12, + y: UIApplication.shared.statusBarHeight + consoleSize.height / 2 + 5), + CGPoint(x: UIScreen.size.width - consoleSize.width / 2 - 12, + y: UIApplication.shared.statusBarHeight + consoleSize.height / 2 + 5), + CGPoint(x: consoleSize.width / 2 + 12, + y: UIScreen.size.height - consoleSize.height / 2 - 56), + CGPoint(x: UIScreen.size.width - consoleSize.width / 2 - 12, + y: UIScreen.size.height - consoleSize.height / 2 - 56)] + + lazy var initialViewLocation: CGPoint = .zero + + override init() { + super.init() + + // Configure console window. + let windowScene = UIApplication.shared + .connectedScenes + .filter { $0.activationState == .foregroundActive } + .first + + if let windowScene = windowScene as? UIWindowScene { + consoleWindow = ConsoleWindow(windowScene: windowScene) + consoleWindow?.frame = UIScreen.main.bounds + consoleWindow?.windowLevel = UIWindow.Level.normal + consoleWindow?.isHidden = false + consoleWindow?.addSubview(consoleView) + + UIWindow.swizzleStatusBarAppearanceOverride + } + + // Configure console view. + consoleView.frame.size = consoleSize + consoleView.backgroundColor = .black + + consoleView.layer.shadowRadius = 16 + consoleView.layer.shadowOpacity = 0.5 + consoleView.layer.shadowOffset = CGSize(width: 0, height: 2) + consoleView.center = possibleEndpoints.first! + consoleView.alpha = 0 + + consoleView.layer.borderWidth = 1 + consoleView.layer.borderColor = UIColor(white: 1, alpha: 0.08).cgColor + + consoleView.layer.cornerRadius = 19 + consoleView.layer.cornerCurve = .continuous + + // Configure text view. + consoleTextView.frame = CGRect(x: 0, y: 2, width: consoleSize.width, height: consoleSize.height - 4) + consoleTextView.isEditable = false + consoleTextView.backgroundColor = .clear + consoleTextView.textContainerInset = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10) + + consoleTextView.isSelectable = false + consoleTextView.showsVerticalScrollIndicator = false + consoleTextView.contentInsetAdjustmentBehavior = .never + consoleView.addSubview(consoleTextView) + + // Configure gesture recognizers. + let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(consolePiPPanner(recognizer:))) + panRecognizer.maximumNumberOfTouches = 1 + panRecognizer.delegate = self + + let tapRecognizer = UITapStartEndGestureRecognizer(target: self, action: #selector(consolePiPTapStartEnd(recognizer:))) + tapRecognizer.delegate = self + + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressAction(recognizer:))) + longPressRecognizer.minimumPressDuration = 0.1 + + consoleView.addGestureRecognizer(panRecognizer) + consoleView.addGestureRecognizer(tapRecognizer) + consoleView.addGestureRecognizer(longPressRecognizer) + + // Prepare menu button. + let diameter = CGFloat(25) + + menuButton = UIButton(frame: CGRect(x: consoleView.bounds.width - diameter - (consoleView.layer.cornerRadius - diameter / 2), + y: consoleView.bounds.height - diameter - (consoleView.layer.cornerRadius - diameter / 2), + width: diameter, height: diameter)) + menuButton.layer.cornerRadius = diameter / 2 + menuButton.backgroundColor = UIColor(white: 1, alpha: 0.20) + + let ellipsisImage = UIImageView(image: UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16))) + ellipsisImage.frame.size = menuButton!.bounds.size + ellipsisImage.contentMode = .center + menuButton.addSubview(ellipsisImage) + + menuButton.tintColor = UIColor(white: 1, alpha: 0.75) + menuButton.menu = makeMenu() + menuButton.showsMenuAsPrimaryAction = true + consoleView.addSubview(menuButton!) + + UIView.swizzleDebugBehaviour + + _print(consoleView) + _print(menuButton) + _print("Hello, world!") + } + + public var isVisible = false { + + didSet { + guard oldValue != isVisible else { return } + + if isVisible { + consoleView.transform = .init(scaleX: 0.9, y: 0.9) + UIViewPropertyAnimator(duration: 0.5, dampingRatio: 0.6) { [self] in + consoleView.transform = .init(scaleX: 1, y: 1) + }.startAnimation() + UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { [self] in + consoleView.alpha = 1 + }.startAnimation() + } else { + UIViewPropertyAnimator(duration: 0.25, dampingRatio: 1) { [self] in + consoleView.transform = .init(scaleX: 0.9, y: 0.9) + consoleView.alpha = 0 + }.startAnimation() + } + } + } + + private var debugBordersEnabled = false { + didSet { + GLOBAL_DEBUG_BORDERS = debugBordersEnabled + guard debugBordersEnabled else { + GLOBAL_BORDER_TRACKERS.forEach { + $0.deactivate() + } + GLOBAL_BORDER_TRACKERS = [] + return + } + + func subviewsRecursive(in _view: UIView) -> [UIView] { + return _view.subviews + _view.subviews.flatMap { subviewsRecursive(in: $0) } + } + + var allViews: [UIView] = [] + + for window in UIApplication.shared.windows { + allViews.append(contentsOf: subviewsRecursive(in: window)) + } + allViews.forEach { + let tracker = BorderManager(view: $0) + GLOBAL_BORDER_TRACKERS.append(tracker) + tracker.activate() + } + } + } + + @objc func toggleLock() { + scrollLocked.toggle() + } + + func toggleVisibility() { + if isVisible { + UIViewPropertyAnimator(duration: 0.25, dampingRatio: 1) { [self] in + consoleView.transform = .init(scaleX: 0.9, y: 0.9) + consoleView.alpha = 0 + }.startAnimation() + + isVisible = false + } else { + UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in + consoleView.transform = .init(scaleX: 1, y: 1) + consoleView.alpha = 1 + }.startAnimation() + + isVisible = true + } + + // Renders color properly (for dark appearance). + consoleView.backgroundColor = .black + } + + public func _print(_ items: Any) { + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = 7 + + let attributes: [NSAttributedString.Key: Any] = [ + .paragraphStyle: paragraphStyle, + .foregroundColor: UIColor.white, + .font: UIFont.systemFont(ofSize: 7, weight: .semibold, design: .monospaced) + ] + + let string: String = { + if consoleTextView.attributedText.string == "" { + return "\(items)" + } else { + return "\(items)\n" + consoleTextView.text + } + }() + + consoleTextView.attributedText = NSAttributedString(string: string, attributes: attributes) + } + + public func clear() { + consoleTextView.text = "" + } + + func makeMenu() -> UIMenu { + let viewFrames = UIAction(title: debugBordersEnabled ? "Hide View Frames" : "Show View Frames", + image: UIImage(systemName: "rectangle.3.offgrid"), handler: { _ in + self.debugBordersEnabled.toggle() + self.menuButton?.menu = self.makeMenu() + }) + + let respring = UIAction(title: "Restart SpringBoard", + image: UIImage(systemName: "apps.iphone"), handler: { _ in + guard let window = UIApplication.shared.windows.first else { return } + + window.layer.cornerRadius = UIScreen.main.value(forKey: "_displayCornerRadius") as! CGFloat + window.layer.masksToBounds = true + + let animator = UIViewPropertyAnimator(duration: 0.5, dampingRatio: 1) { + window.transform = .init(scaleX: 0.96, y: 0.96) + window.alpha = 0 + } + animator.addCompletion { _ in + while true { + window.snapshotView(afterScreenUpdates: false) + } + } + animator.startAnimation() + }) + + return UIMenu(title: "", children: [viewFrames, respring]) + } + + @objc func longPressAction(recognizer: UILongPressGestureRecognizer) { + switch recognizer.state { + case .began: + feedbackGenerator.selectionChanged() + + scrollLocked = false + + UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in + consoleView.transform = .init(scaleX: 1.04, y: 1.04) + consoleTextView.alpha = 0.5 + }.startAnimation() + case .cancelled, .ended: + + scrollLocked = true + + UIViewPropertyAnimator(duration: 0.8, dampingRatio: 0.5) { [self] in + consoleView.transform = .init(scaleX: 1, y: 1) + }.startAnimation() + + UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in + consoleTextView.alpha = 1 + }.startAnimation() + default: break + } + } + + @objc func consolePiPPanner(recognizer: UIPanGestureRecognizer) { + + if recognizer.state == .began { + initialViewLocation = consoleView.center + } + + guard !scrollLocked else { return } + + let translation = recognizer.translation(in: consoleView.superview) + let velocity = recognizer.velocity(in: consoleView.superview) + + switch recognizer.state { + case .changed: + + consoleView.center.x = initialViewLocation.x + translation.x + consoleView.center.y = initialViewLocation.y + translation.y + + case .ended, .cancelled: + + // After the PiP is thrown, determine the best corner and re-target it there. + let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue + + let projectedPosition = CGPoint( + x: consoleView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate), + y: consoleView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate) + ) + + let nearestTargetPosition = nearestTargetTo(projectedPosition, possibleTargets: possibleEndpoints) + + let relativeInitialVelocity = CGVector( + dx: relativeVelocity(forVelocity: velocity.x, from: consoleView.center.x, to: nearestTargetPosition.x), + dy: relativeVelocity(forVelocity: velocity.y, from: consoleView.center.y, to: nearestTargetPosition.y) + ) + + let timingParameters = UISpringTimingParameters(damping: 1, response: 0.4, initialVelocity: relativeInitialVelocity) + let positionAnimator = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters) + positionAnimator.addAnimations { [self] in + consoleView.center = nearestTargetPosition + } + positionAnimator.startAnimation() + + default: break + } + } + + // Animate touch down. + func consolePiPTouchDown() { + UIViewPropertyAnimator(duration: 1, dampingRatio: 0.5) { [self] in + consoleView.transform = .init(scaleX: 0.96, y: 0.96) + }.startAnimation() + + UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in + if !scrollLocked { + consoleView.backgroundColor = #colorLiteral(red: 0.1331297589, green: 0.1331297589, blue: 0.1331297589, alpha: 1) + } + }.startAnimation() + } + + // Animate touch up. + func consolePiPTouchUp() { + UIViewPropertyAnimator(duration: 0.8, dampingRatio: 0.4) { [self] in + consoleView.transform = .init(scaleX: 1, y: 1) + }.startAnimation() + + UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in + consoleTextView.alpha = 1 + }.startAnimation() + + UIViewPropertyAnimator(duration: 0.75, dampingRatio: 1) { [self] in + consoleView.backgroundColor = .black + }.startAnimation() + } + + // Simulataneously listen to all gesture recognizers. + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + + @objc func consolePiPTapStartEnd(recognizer: UITapStartEndGestureRecognizer) { + switch recognizer.state { + case .began: + consolePiPTouchDown() + case .cancelled: + consolePiPTouchUp() + case .changed: + break + case .ended: + consolePiPTouchUp() + case .failed: + consolePiPTouchUp() + case .possible: + consolePiPTouchUp() + @unknown default: + break + } + } +} + +// Custom window for the console to appear above other windows while passing touches down. +class ConsoleWindow: UIWindow { + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + + let hitView = super.hitTest(point, with: event)! + + return hitView.isKind(of: ConsoleWindow.self) ? nil : hitView + } +} + + +import UIKit.UIGestureRecognizerSubclass + +public class UITapStartEndGestureRecognizer: UITapGestureRecognizer { + override public func touchesBegan(_ touches: Set, with: UIEvent) { + self.state = .began + } + override public func touchesMoved(_ touches: Set, with: UIEvent) { + self.state = .changed + } + override public func touchesEnded(_ touches: Set, with: UIEvent) { + self.state = .ended + } +} + +// MARK: Fun hacks! +extension UIView { + /// Swizzle UIView to use custom frame system when needed. + static let swizzleDebugBehaviour: Void = { + guard let originalMethod = class_getInstanceMethod(UIView.self, #selector(layoutSubviews)), + let swizzledMethod = class_getInstanceMethod(UIView.self, #selector(swizzled_layoutSubviews)) else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + @objc func swizzled_layoutSubviews() { + swizzled_layoutSubviews() + + if GLOBAL_DEBUG_BORDERS { + let tracker = BorderManager(view: self) + GLOBAL_BORDER_TRACKERS.append(tracker) + tracker.activate() + } + } +} + +extension UIWindow { + + /// Make sure this window does not have control over the status bar appearance. + static let swizzleStatusBarAppearanceOverride: Void = { + guard let originalMethod = class_getInstanceMethod(UIWindow.self, NSSelectorFromString("_can" + "Affect" + "Status" + "Bar" + "Appearance")), + let swizzledMethod = class_getInstanceMethod(UIWindow.self, #selector(swizzled_statusBarAppearance)) + else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + @objc func swizzled_statusBarAppearance() -> Bool { + return isKeyWindow + } +} + +#endif diff --git a/Sources/LocalConsole/Extensions.swift b/Sources/LocalConsole/Extensions.swift new file mode 100644 index 0000000..11c1c32 --- /dev/null +++ b/Sources/LocalConsole/Extensions.swift @@ -0,0 +1,38 @@ +// +// Extensions.swift +// +// Created by Duraid Abdul. +// Copyright © 2021 Duraid Abdul. All rights reserved. +// + +#if canImport(UIKit) + +import UIKit + +extension UIScreen { + + /// Screen size. + static var size: CGSize { + return UIScreen.main.bounds.size + } +} + +extension UIApplication { + var statusBarHeight: CGFloat { + if let window = UIApplication.shared.windows.first { + return window.safeAreaInsets.top + } else { + return 0 + } + } +} + +extension UIFont { + class func systemFont(ofSize size: CGFloat, weight: UIFont.Weight, design: UIFontDescriptor.SystemDesign) -> UIFont { + let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).addingAttributes([UIFontDescriptor.AttributeName.traits : [UIFontDescriptor.TraitKey.weight : weight]]).withDesign(design) + + return UIFont(descriptor: descriptor!, size: size) + } +} + +#endif diff --git a/Sources/LocalConsole/GestureEndpointPredictor.swift b/Sources/LocalConsole/GestureEndpointPredictor.swift new file mode 100644 index 0000000..c1a8bc8 --- /dev/null +++ b/Sources/LocalConsole/GestureEndpointPredictor.swift @@ -0,0 +1,84 @@ +// +// GestureEndpointPredictor.swift +// +// Created by Duraid Abdul. +// Copyright © 2021 Duraid Abdul. All rights reserved. +// + +#if canImport(UIKit) + +import UIKit + +extension CGPoint { + + /// Calculates the distance between two points in 2D space. + /// + returns: The distance from this point to the given point. + func distance(to point: CGPoint) -> CGFloat { + // Pythagoras + return sqrt(pow(point.x - self.x, 2) + pow(point.y - self.y, 2)) + } +} + +extension UISpringTimingParameters { + + /** + Simplified spring animation timing parameters. + + - Parameters: + - damping: ζ (damping ratio) + - frequency: T (frequency response) + - initialVelocity: [See Here](https://developer.apple.com/documentation/uikit/uispringtimingparameters/1649909-initialvelocity) + */ + convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) { + // Stiffness represents the spring constant, k + let stiffness = pow(2 * .pi / response, 2) + let dampingCoefficient = 4 * .pi * damping / response + self.init(mass: 1, stiffness: stiffness, damping: dampingCoefficient, initialVelocity: initialVelocity) + } +} + +/** + Calculates a unit vector for the initial velocity of a spring animation. + + - Parameters: + - currentLocation: The current location of the view that will be animated. + - targetLocation: The location that the view will be animated to. + - velocity: The current velocity of the moving view. For more information, see [initialVelocity](https://developer.apple.com/documentation/uikit/uispringtimingparameters/1649909-initialvelocity). + - Returns: + A unit vector representing the initial velocity of the view + + This function can be used to form a CGVector to be used in UISpringTimingParameters. + For more information, see [UISpringTimingParameters](https://developer.apple.com/documentation/uikit/uispringtimingparameters). + */ +func relativeVelocity(forVelocity velocity: CGFloat, from currentLocation: CGFloat, to targetLocation: CGFloat) -> CGFloat { + let travelDistance = (targetLocation - currentLocation) + + // Returns an intitial velocity of 0 if + guard travelDistance != 0 else { + return 0 + } + + return velocity / travelDistance +} + +/// Distance traveled after decelerating to zero velocity at a constant rate. +func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { + return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) +} + +/// Calculates the nearest point from a specified array to the specified point. +func nearestTargetTo(_ point: CGPoint, possibleTargets: [CGPoint]) -> CGPoint { + + var currentShortestDistance = CGFloat.greatestFiniteMagnitude + var nearestEndpoint = CGPoint.zero + for endpoint in possibleTargets { + let distance = point.distance(to: endpoint) + if distance < currentShortestDistance { + nearestEndpoint = endpoint + currentShortestDistance = distance + } + } + return nearestEndpoint +} + +#endif diff --git a/Sources/LocalConsole/LocalConsole.swift b/Sources/LocalConsole/LocalConsole.swift deleted file mode 100644 index b501354..0000000 --- a/Sources/LocalConsole/LocalConsole.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct LocalConsole { - var text = "Hello, World!" -} diff --git a/Tests/LocalConsoleTests/LocalConsoleTests.swift b/Tests/LocalConsoleTests/LocalConsoleTests.swift deleted file mode 100644 index daea508..0000000 --- a/Tests/LocalConsoleTests/LocalConsoleTests.swift +++ /dev/null @@ -1,11 +0,0 @@ - import XCTest - @testable import LocalConsole - - final class LocalConsoleTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(LocalConsole().text, "Hello, World!") - } - }