commit 24ad2a1d023a380d981aa9b5914b6ec9d7c3fa05 Author: Guilherme Rambo Date: Wed Aug 28 11:49:52 2019 -0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb23f59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,106 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xcuserstate +.DS_Store +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +fastlane/report.xml +fastlane/screenshots +fastlane/Preview.html +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/ + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# SVN +.svn/ + +*.xcbkptlist + +Vendor/Dependencies + +*.todo diff --git a/Sheet.xcodeproj/project.pbxproj b/Sheet.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9208b84 --- /dev/null +++ b/Sheet.xcodeproj/project.pbxproj @@ -0,0 +1,395 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + DDF6E75A2316CA9000251A21 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6E7592316CA9000251A21 /* AppDelegate.swift */; }; + DDF6E75C2316CA9000251A21 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6E75B2316CA9000251A21 /* ViewController.swift */; }; + DDF6E75F2316CA9000251A21 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DDF6E75D2316CA9000251A21 /* Main.storyboard */; }; + DDF6E7612316CA9100251A21 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDF6E7602316CA9100251A21 /* Assets.xcassets */; }; + DDF6E7642316CA9100251A21 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DDF6E7622316CA9100251A21 /* LaunchScreen.storyboard */; }; + DDF6E7742316CAA300251A21 /* SheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6E76E2316CAA300251A21 /* SheetViewController.swift */; }; + DDF6E7752316CAA300251A21 /* SheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6E7702316CAA300251A21 /* SheetPresenter.swift */; }; + DDF6E7762316CAA300251A21 /* SheetPresentationWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6E7712316CAA300251A21 /* SheetPresentationWindow.swift */; }; + DDF6E7772316CAA300251A21 /* SheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6E7722316CAA300251A21 /* SheetContainerViewController.swift */; }; + DDF6E77F2316CAEE00251A21 /* FluidTimingCurve.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6E77B2316CAEE00251A21 /* FluidTimingCurve.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + DDF6E7562316CA9000251A21 /* Sheet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sheet.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DDF6E7592316CA9000251A21 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + DDF6E75B2316CA9000251A21 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + DDF6E75E2316CA9000251A21 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + DDF6E7602316CA9100251A21 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DDF6E7632316CA9100251A21 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + DDF6E7652316CA9100251A21 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DDF6E76E2316CAA300251A21 /* SheetViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheetViewController.swift; sourceTree = ""; }; + DDF6E7702316CAA300251A21 /* SheetPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheetPresenter.swift; sourceTree = ""; }; + DDF6E7712316CAA300251A21 /* SheetPresentationWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheetPresentationWindow.swift; sourceTree = ""; }; + DDF6E7722316CAA300251A21 /* SheetContainerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheetContainerViewController.swift; sourceTree = ""; }; + DDF6E77B2316CAEE00251A21 /* FluidTimingCurve.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FluidTimingCurve.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DDF6E7532316CA9000251A21 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + DDF6E74D2316CA8F00251A21 = { + isa = PBXGroup; + children = ( + DDF6E7582316CA9000251A21 /* Sheet */, + DDF6E7572316CA9000251A21 /* Products */, + ); + sourceTree = ""; + }; + DDF6E7572316CA9000251A21 /* Products */ = { + isa = PBXGroup; + children = ( + DDF6E7562316CA9000251A21 /* Sheet.app */, + ); + name = Products; + sourceTree = ""; + }; + DDF6E7582316CA9000251A21 /* Sheet */ = { + isa = PBXGroup; + children = ( + DDF6E7782316CAEE00251A21 /* Animation */, + DDF6E76B2316CAA300251A21 /* Bottom Sheet */, + DDF6E7592316CA9000251A21 /* AppDelegate.swift */, + DDF6E75B2316CA9000251A21 /* ViewController.swift */, + DDF6E75D2316CA9000251A21 /* Main.storyboard */, + DDF6E7602316CA9100251A21 /* Assets.xcassets */, + DDF6E7622316CA9100251A21 /* LaunchScreen.storyboard */, + DDF6E7652316CA9100251A21 /* Info.plist */, + ); + path = Sheet; + sourceTree = ""; + }; + DDF6E76B2316CAA300251A21 /* Bottom Sheet */ = { + isa = PBXGroup; + children = ( + DDF6E76C2316CAA300251A21 /* Chrome */, + DDF6E76F2316CAA300251A21 /* Presentation */, + ); + path = "Bottom Sheet"; + sourceTree = ""; + }; + DDF6E76C2316CAA300251A21 /* Chrome */ = { + isa = PBXGroup; + children = ( + DDF6E76E2316CAA300251A21 /* SheetViewController.swift */, + ); + path = Chrome; + sourceTree = ""; + }; + DDF6E76F2316CAA300251A21 /* Presentation */ = { + isa = PBXGroup; + children = ( + DDF6E7702316CAA300251A21 /* SheetPresenter.swift */, + DDF6E7712316CAA300251A21 /* SheetPresentationWindow.swift */, + DDF6E7722316CAA300251A21 /* SheetContainerViewController.swift */, + ); + path = Presentation; + sourceTree = ""; + }; + DDF6E7782316CAEE00251A21 /* Animation */ = { + isa = PBXGroup; + children = ( + DDF6E77B2316CAEE00251A21 /* FluidTimingCurve.swift */, + ); + path = Animation; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DDF6E7552316CA9000251A21 /* Sheet */ = { + isa = PBXNativeTarget; + buildConfigurationList = DDF6E7682316CA9100251A21 /* Build configuration list for PBXNativeTarget "Sheet" */; + buildPhases = ( + DDF6E7522316CA9000251A21 /* Sources */, + DDF6E7532316CA9000251A21 /* Frameworks */, + DDF6E7542316CA9000251A21 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Sheet; + productName = Sheet; + productReference = DDF6E7562316CA9000251A21 /* Sheet.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DDF6E74E2316CA8F00251A21 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1030; + LastUpgradeCheck = 1030; + ORGANIZATIONNAME = "Guilherme Rambo"; + TargetAttributes = { + DDF6E7552316CA9000251A21 = { + CreatedOnToolsVersion = 10.3; + }; + }; + }; + buildConfigurationList = DDF6E7512316CA8F00251A21 /* Build configuration list for PBXProject "Sheet" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DDF6E74D2316CA8F00251A21; + productRefGroup = DDF6E7572316CA9000251A21 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DDF6E7552316CA9000251A21 /* Sheet */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DDF6E7542316CA9000251A21 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DDF6E7642316CA9100251A21 /* LaunchScreen.storyboard in Resources */, + DDF6E7612316CA9100251A21 /* Assets.xcassets in Resources */, + DDF6E75F2316CA9000251A21 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DDF6E7522316CA9000251A21 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DDF6E7742316CAA300251A21 /* SheetViewController.swift in Sources */, + DDF6E7752316CAA300251A21 /* SheetPresenter.swift in Sources */, + DDF6E7762316CAA300251A21 /* SheetPresentationWindow.swift in Sources */, + DDF6E77F2316CAEE00251A21 /* FluidTimingCurve.swift in Sources */, + DDF6E7772316CAA300251A21 /* SheetContainerViewController.swift in Sources */, + DDF6E75C2316CA9000251A21 /* ViewController.swift in Sources */, + DDF6E75A2316CA9000251A21 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + DDF6E75D2316CA9000251A21 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + DDF6E75E2316CA9000251A21 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + DDF6E7622316CA9100251A21 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + DDF6E7632316CA9100251A21 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + DDF6E7662316CA9100251A21 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DOCUMENTATION_COMMENTS = YES; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.4; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DDF6E7672316CA9100251A21 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DOCUMENTATION_COMMENTS = YES; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DDF6E7692316CA9100251A21 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 6ABEVHK7CE; + INFOPLIST_FILE = Sheet/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.Sheet; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DDF6E76A2316CA9100251A21 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 6ABEVHK7CE; + INFOPLIST_FILE = Sheet/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = codes.rambo.Sheet; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DDF6E7512316CA8F00251A21 /* Build configuration list for PBXProject "Sheet" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DDF6E7662316CA9100251A21 /* Debug */, + DDF6E7672316CA9100251A21 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DDF6E7682316CA9100251A21 /* Build configuration list for PBXNativeTarget "Sheet" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DDF6E7692316CA9100251A21 /* Debug */, + DDF6E76A2316CA9100251A21 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = DDF6E74E2316CA8F00251A21 /* Project object */; +} diff --git a/Sheet.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Sheet.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..6f6d5fa --- /dev/null +++ b/Sheet.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Sheet.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Sheet.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Sheet.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Sheet/Animation/FluidTimingCurve.swift b/Sheet/Animation/FluidTimingCurve.swift new file mode 100644 index 0000000..df61af2 --- /dev/null +++ b/Sheet/Animation/FluidTimingCurve.swift @@ -0,0 +1,67 @@ +// +// FluidTimingCurve.swift +// Sheet +// +// Created by Guilherme Rambo on 05/08/19. +// Copyright © 2019 Peixe Urbano. All rights reserved. +// + +import UIKit + +public final class FluidTimingCurve: NSObject, UITimingCurveProvider { + + public let initialVelocity: CGVector + let mass: CGFloat + let stiffness: CGFloat + let damping: CGFloat + + public init(velocity: CGVector, stiffness: CGFloat = 400, damping: CGFloat = 30, mass: CGFloat = 1.0) { + self.initialVelocity = velocity + self.stiffness = stiffness + self.damping = damping + self.mass = mass + + super.init() + } + + public func encode(with aCoder: NSCoder) { + fatalError("Not supported") + } + + public init?(coder aDecoder: NSCoder) { + fatalError("Not supported") + } + + public func copy(with zone: NSZone? = nil) -> Any { + return FluidTimingCurve(velocity: initialVelocity) + } + + public var timingCurveType: UITimingCurveType { + return .composed + } + + public var cubicTimingParameters: UICubicTimingParameters? { + return .init(animationCurve: .easeIn) + } + + public var springTimingParameters: UISpringTimingParameters? { + return UISpringTimingParameters(mass: mass, stiffness: stiffness, damping: damping, initialVelocity: initialVelocity) + } + +} + +public extension UISpringTimingParameters { + + /// A design-friendly way to create a spring timing curve. + /// + /// - Parameters: + /// - damping: The 'bounciness' of the animation. Value must be between 0 and 1. + /// - response: The 'speed' of the animation. + /// - initialVelocity: The vector describing the starting motion of the property. Optional, default is `.zero`. + convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) { + let stiffness = pow(2 * .pi / response, 2) + let damp = 4 * .pi * damping / response + self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity) + } + +} diff --git a/Sheet/AppDelegate.swift b/Sheet/AppDelegate.swift new file mode 100644 index 0000000..f9454e7 --- /dev/null +++ b/Sheet/AppDelegate.swift @@ -0,0 +1,46 @@ +// +// AppDelegate.swift +// Sheet +// +// Created by Guilherme Rambo on 28/08/19. +// Copyright © 2019 Guilherme Rambo. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + 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 invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + 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) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + 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) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + +} + diff --git a/Sheet/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sheet/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/Sheet/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Sheet/Assets.xcassets/Contents.json b/Sheet/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Sheet/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Sheet/Base.lproj/LaunchScreen.storyboard b/Sheet/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..bfa3612 --- /dev/null +++ b/Sheet/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sheet/Base.lproj/Main.storyboard b/Sheet/Base.lproj/Main.storyboard new file mode 100644 index 0000000..16fab98 --- /dev/null +++ b/Sheet/Base.lproj/Main.storyboard @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sheet/Bottom Sheet/Chrome/SheetViewController.swift b/Sheet/Bottom Sheet/Chrome/SheetViewController.swift new file mode 100644 index 0000000..b0171e0 --- /dev/null +++ b/Sheet/Bottom Sheet/Chrome/SheetViewController.swift @@ -0,0 +1,217 @@ +// +// SheetViewController.swift +// Sheet +// +// Created by Guilherme Rambo on 05/08/19. +// Copyright © 2019 Peixe Urbano. All rights reserved. +// + +import UIKit + +class SheetViewController: UIViewController { + + let metrics: SheetMetrics + + init(metrics: SheetMetrics) { + self.metrics = metrics + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + private var container: SheetContainerViewController? { + return parent as? SheetContainerViewController + } + + var rubberBandingStartHandler: (() -> Void)? + var rubberBandingUpdateHandler: ((CGFloat) -> Void)? + var rubberBandingFinishedHandler: (() -> Void)? + + var isScrollingEnabled = true + + private let scrollViewAtTheTopDeltaThreshold: CGFloat = 3 + + var isScrollViewAtTheTop: Bool { + return abs(scrollView.contentOffset.y - scrollView.contentInset.top * -1) < scrollViewAtTheTopDeltaThreshold + } + + private lazy var contentView: SheetContentView = { + let v = SheetContentView(metrics: self.metrics) + + v.layer.cornerRadius = view.layer.cornerRadius + v.layer.maskedCorners = view.layer.maskedCorners + v.autoresizingMask = [.flexibleWidth, .flexibleHeight] + v.clipsToBounds = true + + return v + }() + + private(set) lazy var scrollView: UIScrollView = { + let v = UIScrollView() + + v.translatesAutoresizingMaskIntoConstraints = false + v.delegate = self + + return v + }() + + override func loadView() { + view = UIView() + + view.backgroundColor = #colorLiteral(red: 0.9411764706, green: 0.9411764706, blue: 0.9411764706, alpha: 1) + view.layer.cornerRadius = metrics.cornerRadius + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = Float(metrics.shadowOpacity) + view.layer.shadowRadius = metrics.shadowRadius + view.layer.shadowOffset = CGSize(width: 0, height: -1) + + contentView.frame = view.bounds + view.addSubview(contentView) + + contentView.addSubview(scrollView) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private weak var contentController: UIViewController? + + func installContent(_ content: UIViewController) { + contentController = content + + addChild(content) + content.view.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(content.view) + + content.didMove(toParent: self) + + NSLayoutConstraint.activate([ + content.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + content.view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + content.view.topAnchor.constraint(equalTo: scrollView.topAnchor), + content.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + content.view.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + content.view.heightAnchor.constraint(equalTo: scrollView.heightAnchor) + ]) + } + + var availableHeight: CGFloat { + guard let parentView = parent?.view else { return 0 } + + return parentView.bounds.intersection(view.frame).height + } + + private var scrolledUpOnFirstContentInsetUpdate = false + + private var initialContentOffset: CGPoint = .zero + private var previousContentOffset: CGPoint = .zero + + func updateContentInsets() { + scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: metrics.trueSheetHeight - availableHeight, right: 0) + + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(scrollUpOnFirstContentInsetUpdateIfNeeded), object: nil) + perform(#selector(scrollUpOnFirstContentInsetUpdateIfNeeded), with: nil, afterDelay: 0) + } + + @objc private func scrollUpOnFirstContentInsetUpdateIfNeeded() { + guard !scrollView.contentInset.top.isZero else { return } + guard !scrolledUpOnFirstContentInsetUpdate else { return } + scrolledUpOnFirstContentInsetUpdate = true + + scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) + } + + deinit { + print("\(String(describing: type(of: self))) DEINIT") + } + +} + +final class SheetContentView: UIView { + + let metrics: SheetMetrics + + init(metrics: SheetMetrics) { + self.metrics = metrics + + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError() + } + +} + +extension SheetViewController: UIScrollViewDelegate { + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + initialContentOffset = scrollView.contentOffset + } + + func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { + rubberBandingStartHandler?() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + rubberBandingFinishedHandler?() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + defer { previousContentOffset = scrollView.contentOffset } + + var isRubberBandingUp = false + var bandOffset: CGFloat = 0 + + if scrollView.isDecelerating { + let effectiveOffset = scrollView.contentOffset.y + scrollView.contentInset.top + + if scrollView.contentOffset.y < initialContentOffset.y { + if effectiveOffset < 0 { + isRubberBandingUp = true + bandOffset = effectiveOffset + rubberBandingUpdateHandler?(effectiveOffset) + } + } + } + + if isRubberBandingUp { + // Counteract rubber banding by shifting contents so that they are flush with the top. + // We can't use setContentOffset here because that kills the rubber banding. + CATransaction.begin() + CATransaction.setDisableActions(true) + CATransaction.setAnimationDuration(0) + + contentController?.view.layer.transform = CATransform3DMakeTranslation(0, bandOffset, 0) + + CATransaction.commit() + } else { + let currentTransform = contentController?.view.layer.transform ?? CATransform3DIdentity + + if !CATransform3DIsIdentity(currentTransform) { + CATransaction.begin() + CATransaction.setDisableActions(true) + CATransaction.setAnimationDuration(0) + + contentController?.view.layer.transform = CATransform3DIdentity + + CATransaction.commit() + } + } + + guard isScrollingEnabled else { + scrollView.setContentOffset(previousContentOffset, animated: false) + return + } + } + +} diff --git a/Sheet/Bottom Sheet/Presentation/SheetContainerViewController.swift b/Sheet/Bottom Sheet/Presentation/SheetContainerViewController.swift new file mode 100644 index 0000000..671ce52 --- /dev/null +++ b/Sheet/Bottom Sheet/Presentation/SheetContainerViewController.swift @@ -0,0 +1,555 @@ +// +// SheetContainerViewController.swift +// Sheet +// +// Created by Guilherme Rambo on 05/08/19. +// Copyright © 2019 Peixe Urbano. All rights reserved. +// + +import UIKit + +public struct SheetMetrics { + public static let `default` = SheetMetrics() + + public let bufferHeight: CGFloat = 400 + public let cornerRadius: CGFloat = 10 + public let shadowRadius: CGFloat = 10 + public let shadowOpacity: CGFloat = 0.12 + + public var trueSheetHeight: CGFloat { + return UIScreen.main.bounds.height + bufferHeight + } +} + +/// Defines snapping positions for the sheet. +public enum SheetDetent: String, CaseIterable { + + /// A detent where the sheet will have its maximum height and have + /// its top edge close to the top edge of the screen. + case maximum + + /// A detent where the sheet's height will be about half the height + /// of the screen, with its top edge close to the middle of the screen. + case middle + + /// A detent at which the sheet's contents are effectively hidden, + /// but the sheet's header still peek's through the bottom of the screen, + /// allowing the user to expand it. + case minimum + + /// The velocity at which the sheet will ignore the middle detent and transition directly + /// from the maximum detent to the minimum detent when swiping down. + static let thresholdVelocityForSkippingMiddleDetent: CGFloat = 2000 + + /// The velocity at which the sheet will be dismissed instead of snapping + /// to a detent when flung down. + static let thresholdVelocityForFlingDismissal: CGFloat = 4000 + + /// The velocity at which the sheet will snap to the middle detent when flung upwards from + /// the minimum detent, ignoring the distance between the current position and the minimum detent. + static let thresholdVelocityForEnforcingMinimumToMiddleTransition: CGFloat = 900 + +} + +extension SheetDetent: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .maximum: return "" + case .middle: return "" + case .minimum: return "" + } + } +} + +class SheetContainerViewController: UIViewController { + + var transitionToMaximumDetentProgressDidChange: ((CGFloat) -> Void)? + var performSnapCompanionAnimations: ((SheetDetent) -> Void)? + + let sheetContentController: UIViewController + let initialDetent: SheetDetent + let metrics: SheetMetrics + + let allowedDetents: [SheetDetent] + let dismissWhenFlungDown: Bool + + weak var presentingSheetPresenter: SheetPresenter? + + init(sheetContentController: UIViewController, + presentingSheetPresenter: SheetPresenter?, + initialDetent: SheetDetent = .middle, + allowedDetents: [SheetDetent] = SheetDetent.allCases, + metrics: SheetMetrics = .default, + dismissWhenFlungDown: Bool = false) + { + self.sheetContentController = sheetContentController + self.presentingSheetPresenter = presentingSheetPresenter + self.initialDetent = initialDetent + self.metrics = metrics + self.allowedDetents = allowedDetents + self.dismissWhenFlungDown = dismissWhenFlungDown + + super.init(nibName: nil, bundle: nil) + } + + private var overrideStatusBarStyle: UIStatusBarStyle? { + didSet { + setNeedsStatusBarAppearanceUpdate() + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return overrideStatusBarStyle ?? super.preferredStatusBarStyle + } + + override var childForStatusBarStyle: UIViewController? { + return overrideStatusBarStyle != nil ? nil : sheetContentController + } + + override var childForStatusBarHidden: UIViewController? { + return sheetContentController + } + + required init?(coder: NSCoder) { + fatalError() + } + + #warning("TODO: Rubber band and limit maximum sheet height while interactively moving") + private var maximumSheetHeight: CGFloat { + return value(for: .maximum) - view.safeAreaInsets.top + } + + #warning("TODO: Rubber band and limit minimum sheet height while interactively moving") + private var minimumSheetHeight: CGFloat { + return metrics.shadowRadius + view.safeAreaInsets.bottom + } + + private func heightToBottom(constant: CGFloat) -> CGFloat { + return metrics.trueSheetHeight - constant + } + + private func normalize(_ value: CGFloat, range: ClosedRange) -> CGFloat { + return (value - range.lowerBound) / (range.upperBound - range.lowerBound) + } + + private lazy var availableHeightOnMiddleDetent = abs(value(for: .middle) - metrics.trueSheetHeight) + private lazy var availableHeightOnMaximumDetent = abs(value(for: .maximum) - metrics.trueSheetHeight) + + var maximumDetentAnimationProgress: CGFloat { + guard sheetBottomConstraint.constant < value(for: .middle) else { return 0 } + + let currentAvailableHeight = sheetController.availableHeight + + let rawMin = availableHeightOnMiddleDetent / availableHeightOnMaximumDetent + let rawValue = currentAvailableHeight / availableHeightOnMaximumDetent + + return normalize(rawValue, range: rawMin...1) + } + + private func value(for detent: SheetDetent) -> CGFloat { + switch detent { + case .maximum: return heightToBottom(constant: UIScreen.main.bounds.height * 0.92) + case .middle: return heightToBottom(constant: UIScreen.main.bounds.height * 0.54) + case .minimum: return heightToBottom(constant: UIScreen.main.bounds.height * 0.16) + } + } + + private var flingDownDismissVelocity: CGFloat? + + private var snappingCancelled = false { + didSet { + if snappingCancelled { view.isUserInteractionEnabled = false } + } + } + + private func closestSnappingDetent(for height: CGFloat, velocity: CGPoint) -> SheetDetent { +// print("SNAP velocity = \(velocity)") + + var winner: SheetDetent = .maximum + + let validDetents: [SheetDetent] + + if dismissWhenFlungDown, velocity.y > 0, abs(velocity.y) > SheetDetent.thresholdVelocityForFlingDismissal { + snappingCancelled = true + flingDownDismissVelocity = velocity.y + + presentingSheetPresenter?.dismiss() + + return .minimum + } + + if abs(velocity.y) > SheetDetent.thresholdVelocityForSkippingMiddleDetent { + if velocity.y < 0 { + // Swiping up really hard, force maximum detent + validDetents = [.maximum] + } else { + // Swiping hard in any direction, ignore middle detent + validDetents = allowedDetents.filter({ $0 != .middle }) + } + } else if velocity.y < 0, + abs(velocity.y) > SheetDetent.thresholdVelocityForEnforcingMinimumToMiddleTransition, + sheetBottomConstraint.constant > value(for: .middle) + { + // Swiping up hard in between minimum and medium detent, force middle detent + validDetents = [.middle] + } else { + validDetents = allowedDetents + } + + for detent in validDetents { + if abs(height - value(for: detent)) < abs(height - value(for: winner)) { + winner = detent + } + } + + return winner + } + + private weak var currentAnimator: UIViewPropertyAnimator? + + private func timingCurve(with velocity: CGFloat) -> FluidTimingCurve { + let damping: CGFloat = velocity.isZero ? 100 : 30 + + return FluidTimingCurve( + velocity: CGVector(dx: velocity, dy: velocity), + stiffness: 400, + damping: damping + ) + } + + private func estimateTargetDetent(with velocity: CGFloat) -> SheetDetent { + return .maximum + } + + private func snap(to detent: SheetDetent, with velocity: CGPoint = .zero, completion: (() -> Void)? = nil) { + guard !snappingCancelled else { return } + + if currentAnimator?.state == .some(.active) { + currentAnimator?.stopAnimation(true) + } + + let targetValue = value(for: detent) + // the 0.5 is to ensure there's always some distance for the gesture to work with + let distanceY = (sheetBottomConstraint.constant - 0.5) - targetValue + + let effectiveVelocity = velocity.y.isInfinite || velocity.y.isNaN ? 2000 : velocity.y + + let initialVelocityY = distanceY.isZero ? 0 : effectiveVelocity/distanceY * -1 + + let timing = timingCurve(with: initialVelocityY) + let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: timing) + + animator.isUserInteractionEnabled = true + + self.sheetBottomConstraint.constant = targetValue + + animator.addAnimations { + self.performSnapCompanionAnimations?(detent) + + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + self.sheetController.updateContentInsets() + + if detent == .maximum { + self.dimmingView.alpha = self.maximumDimmingAlpha + self.overrideStatusBarStyle = .lightContent + } else { + if detent == .minimum { + self.dimmingView.alpha = 0 + } else { + self.dimmingView.alpha = self.minimumDimmingAlpha + } + + self.overrideStatusBarStyle = nil + } + } + + animator.addCompletion { pos in + guard pos == .end else { return } + + completion?() + } + + currentAnimator = animator + + animator.startAnimation() + } + + func dismissSheet(coordinator: UIViewControllerTransitionCoordinator? = nil, duration: TimeInterval = 0.3, completion: (() -> Void)? = nil) { + let targetValue = metrics.trueSheetHeight + + let animationBlock = { + self.dimmingView.alpha = 0 + + self.performSnapCompanionAnimations?(.minimum) + + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + self.sheetController.updateContentInsets() + + self.overrideStatusBarStyle = nil + } + + if let coordinator = coordinator { + coordinator.animate(alongsideTransition: { _ in + animationBlock() + }, completion: { _ in + completion?() + }) + } else { + let distanceY = sheetBottomConstraint.constant - targetValue + + let effectiveVelocity: CGFloat + + if let flingVelocity = flingDownDismissVelocity { + effectiveVelocity = flingVelocity.isInfinite || flingVelocity.isNaN ? 2000 : flingVelocity + } else { + effectiveVelocity = 0 + } + + let initialVelocityY = distanceY.isZero ? 0 : effectiveVelocity/distanceY * -1 + + let timing = timingCurve(with: initialVelocityY) + + let animator = UIViewPropertyAnimator(duration: duration, timingParameters: timing) + + animator.isUserInteractionEnabled = true + + self.sheetBottomConstraint.constant = targetValue + + animator.addAnimations { + animationBlock() + } + + animator.addCompletion { pos in + guard pos == .end else { return } + + completion?() + } + + animator.startAnimation() + } + } + + private lazy var sheetBottomConstraint: NSLayoutConstraint = { + return sheetController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: metrics.trueSheetHeight) + }() + + private(set) lazy var sheetController: SheetViewController = { + let v = SheetViewController(metrics: self.metrics) + + v.rubberBandingStartHandler = { [weak self] in + self?.registerRubberBandingStart() + } + v.rubberBandingUpdateHandler = { [weak self] offset in + self?.followSheetScrollViewRubberBanding(with: offset) + } + v.rubberBandingFinishedHandler = { [weak self] in + self?.rubberBandingFinished() + } + + return v + }() + + private lazy var dimmingView: UIView = { + let v = UIView() + + v.backgroundColor = .black + v.alpha = 0 + v.autoresizingMask = [.flexibleWidth, .flexibleHeight] + v.frame = view.bounds + + return v + }() + + private lazy var panGesture: UIPanGestureRecognizer = { + let g = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) + + g.delegate = self + + return g + }() + + override func loadView() { + view = SheetContainerView(metrics: metrics) + + view.addSubview(dimmingView) + + addChild(sheetController) + view.addSubview(sheetController.view) + sheetController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + sheetController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + sheetController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + sheetController.view.heightAnchor.constraint(equalToConstant: metrics.trueSheetHeight), + sheetBottomConstraint + ]) + + sheetController.installContent(sheetContentController) + + sheetController.view.addGestureRecognizer(panGesture) + } + + private var snappedToInitialDetent = false + + private func snapToInitialDetent() { + NSObject.cancelPreviousPerformRequests(withTarget: self) + perform(#selector(doSnapToInitialDetent), with: nil, afterDelay: 0) + } + + @objc private func doSnapToInitialDetent() { + snap(to: initialDetent) + } + + override func viewDidLoad() { + super.viewDidLoad() + + snapToInitialDetent() + } + + private var isDraggingSheet = false + private var lastTranslationY: CGFloat = 0 + private var initialSheetHeightConstant: CGFloat = 0 + + private var minimumDimmingAlpha: CGFloat = 0.1 + private var maximumDimmingAlpha: CGFloat = 0.5 + + private func snapToClosestDetent(with velocity: CGPoint) { + let target = closestSnappingDetent(for: sheetBottomConstraint.constant, velocity: velocity) + + snap(to: target, with: velocity) + } + + @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { + let translation = recognizer.translation(in: view) + let velocity = recognizer.velocity(in: view) + + switch recognizer.state { + case .began: + isDraggingSheet = true + initialSheetHeightConstant = sheetBottomConstraint.constant + case .ended, .cancelled, .failed: + isDraggingSheet = false + + if !sheetController.isScrollingEnabled { + snapToClosestDetent(with: velocity) + } + + lastTranslationY = 0 + case .changed: + let newConstant = sheetBottomConstraint.constant + (translation.y - lastTranslationY) + + if sheetController.isScrollViewAtTheTop { + if newConstant > value(for: .maximum) || translation.y > 0 { + sheetBottomConstraint.constant = newConstant + + sheetController.updateContentInsets() + + sheetController.isScrollingEnabled = false + + progressMaximumDetentInteractiveAnimation() + } else { + sheetController.isScrollingEnabled = true + } + } else { + sheetController.isScrollingEnabled = true + } + + lastTranslationY = translation.y + default: + break + } + } + + private var sheetBottomConstantAtRubberBandingStart: CGFloat = 0 + + private func registerRubberBandingStart() { + sheetBottomConstantAtRubberBandingStart = sheetBottomConstraint.constant + } + + private func rubberBandingFinished() { + guard currentAnimator?.state != .active else { return } + + snapToClosestDetent(with: .zero) + } + + private func followSheetScrollViewRubberBanding(with offset: CGFloat) { + guard offset < 0 else { return } // only follow rubber banding when at the top + + sheetBottomConstraint.constant = sheetBottomConstantAtRubberBandingStart - offset + + progressMaximumDetentInteractiveAnimation() + } + + private func progressMaximumDetentInteractiveAnimation() { + let progressToMaxDetent = maximumDetentAnimationProgress + + if progressToMaxDetent >= 0.5 { + overrideStatusBarStyle = .lightContent + } else { + overrideStatusBarStyle = nil + } + + if sheetBottomConstraint.constant < value(for: .minimum) { + let duration: TimeInterval = dimmingView.alpha == 0 ? 0.3 : 0 + + UIView.animate(withDuration: duration) { + self.dimmingView.alpha = self.minimumDimmingAlpha + self.maximumDimmingAlpha * progressToMaxDetent + } + } else { + dimmingView.alpha = 0 + } + + transitionToMaximumDetentProgressDidChange?(progressToMaxDetent) + } + + deinit { + print("\(String(describing: type(of: self))) DEINIT") + } + +} + +private final class SheetContainerView: UIView { + + let metrics: SheetMetrics + + init(metrics: SheetMetrics) { + self.metrics = metrics + + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError() + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let result = super.hitTest(point, with: event) else { return nil } + + return result.isSheetDescendant ? result : nil + } + +} + +extension UIView { + var isSheetDescendant: Bool { + var currentView: UIView? = self + + repeat { + if currentView is SheetContentView { return true } + + currentView = currentView?.superview + } while currentView != nil + + return false + } +} + +extension SheetContainerViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } +} diff --git a/Sheet/Bottom Sheet/Presentation/SheetPresentationWindow.swift b/Sheet/Bottom Sheet/Presentation/SheetPresentationWindow.swift new file mode 100644 index 0000000..74a91b3 --- /dev/null +++ b/Sheet/Bottom Sheet/Presentation/SheetPresentationWindow.swift @@ -0,0 +1,21 @@ +// +// SheetPresentationWindow.swift +// Sheet +// +// Created by Guilherme Rambo on 05/08/19. +// Copyright © 2019 Peixe Urbano. All rights reserved. +// + +import UIKit + +final class SheetPresentationWindow: UIWindow { + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return rootViewController?.view.hitTest(point, with: event) != nil + } + + deinit { + print("\(String(describing: type(of: self))) DEINIT") + } + +} diff --git a/Sheet/Bottom Sheet/Presentation/SheetPresenter.swift b/Sheet/Bottom Sheet/Presentation/SheetPresenter.swift new file mode 100644 index 0000000..3ee6b2b --- /dev/null +++ b/Sheet/Bottom Sheet/Presentation/SheetPresenter.swift @@ -0,0 +1,142 @@ +// +// SheetPresenter.swift +// Sheet +// +// Created by Guilherme Rambo on 05/08/19. +// Copyright © 2019 Peixe Urbano. All rights reserved. +// + +import UIKit + +/// Allows a controller to present another controller as a sheet that can be +/// snapped to different positions. +public final class SheetPresenter: NSObject { + + private var window: SheetPresentationWindow? + + private var container: SheetContainerViewController? + + private weak var presenter: UIViewController? + + private var presenterWindow: UIWindow? { + return presenter?.view.window + } + + /// Whether the sheet is currently being presented. + private(set) var isPresentingSheet = false + + /// Starts the presentation of a controller as a sheet. + /// - Parameter presenter: The view controller that's presenting the sheet. + /// - Parameter content: The view controller that will be inside the sheet. + /// - Parameter initialDetent: The initial position of the sheet (defaults to `.middle`) + /// - Parameter allowedDetents: The allowed snapping positions for the sheet (defaults to all positions). + /// - Parameter dismissWhenFlungDown: Whether the sheet can be dismissed when flung down by the user. + /// - Parameter metrics: Metrics defining the look of the sheet (can be ommited to use default metrics). + public func presentSheet(from presenter: UIViewController, + with content: UIViewController, + initialDetent: SheetDetent = .middle, + allowedDetents: [SheetDetent] = SheetDetent.allCases, + dismissWhenFlungDown: Bool = false, + metrics: SheetMetrics = .default) + { + guard !isPresentingSheet else { return } + + assert(presenter.view.window != nil, "Tried to present a sheet from a view controller that's not currently on screen!") + + guard window == nil else { return } + + self.presenter = presenter + presenterWindow?.clipsToBounds = true + + let w = SheetPresentationWindow(frame: presenter.view.bounds) + let c = SheetContainerViewController( + sheetContentController: content, + presentingSheetPresenter: self, + initialDetent: initialDetent, + allowedDetents: allowedDetents, + metrics: metrics, + dismissWhenFlungDown: dismissWhenFlungDown + ) + + w.rootViewController = c + w.windowLevel = .alert + w.makeKeyAndVisible() + + self.window = w + self.container = c + + c.performSnapCompanionAnimations = { [weak self] detent in + guard let self = self else { return } + + switch detent { + case .maximum: + self.animateToMaximumDetent() + default: + self.animateToNonMaximumDetent() + } + } + + c.transitionToMaximumDetentProgressDidChange = { [weak self] progress in + self?.updateSheetAnimationStateToMaximumDetent(with: progress) + } + + isPresentingSheet = true + } + + /// Dismisses the sheet. + /// - Parameter coordinator: Perform the dismissal together with an animated transition. + /// - Parameter completion: Called when the dismissal animation has completed. + public func dismiss(with coordinator: UIViewControllerTransitionCoordinator? = nil, completion: (() -> Void)? = nil) { + container?.dismissSheet(duration: 0.4) { [weak self] in + completion?() + + self?.window?.resignKey() + self?.window?.isHidden = true + self?.window?.removeFromSuperview() + + self?.container = nil + self?.presenter = nil + self?.window = nil + + self?.isPresentingSheet = false + } + } + + private var presenterTranslationWhenAtMaximumDetent: CGFloat { + let safeAreaTop = container?.view.safeAreaInsets.top ?? 0 + + return safeAreaTop <= 20 ? safeAreaTop + 22 : safeAreaTop + 8 + } + + private let presenterHorizontalScaleWhenAtMaximumDetent: CGFloat = 0.914 + + private let presenterCornerRadiusWhenAtMaximumDetent: CGFloat = 10 + + private let presenterScaleWhenAtMaximumDetent: CGFloat = 0.9 + + private func updateSheetAnimationStateToMaximumDetent(with progress: CGFloat) { + let translation = presenterTranslationWhenAtMaximumDetent * progress + let radius = presenterCornerRadiusWhenAtMaximumDetent * progress + let scale = min(1, 1 - progress + presenterScaleWhenAtMaximumDetent) + + let translationTransform = CATransform3DMakeTranslation(0, translation, 0) + let scaleTransform = CATransform3DMakeScale(scale, 1, 1) + + presenterWindow?.layer.transform = CATransform3DConcat(translationTransform, scaleTransform) + presenterWindow?.layer.cornerRadius = radius + } + + private func animateToMaximumDetent() { + let translationTransform = CATransform3DMakeTranslation(0, presenterTranslationWhenAtMaximumDetent, 0) + let scaleTransform = CATransform3DMakeScale(presenterScaleWhenAtMaximumDetent, 1, 1) + + presenterWindow?.layer.transform = CATransform3DConcat(translationTransform, scaleTransform) + presenterWindow?.layer.cornerRadius = presenterCornerRadiusWhenAtMaximumDetent + } + + private func animateToNonMaximumDetent() { + presenterWindow?.layer.transform = CATransform3DIdentity + presenterWindow?.layer.cornerRadius = 0 + } + +} diff --git a/Sheet/Info.plist b/Sheet/Info.plist new file mode 100644 index 0000000..89d7858 --- /dev/null +++ b/Sheet/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Sheet/ViewController.swift b/Sheet/ViewController.swift new file mode 100644 index 0000000..57c430f --- /dev/null +++ b/Sheet/ViewController.swift @@ -0,0 +1,28 @@ +// +// ViewController.swift +// Sheet +// +// Created by Guilherme Rambo on 28/08/19. +// Copyright © 2019 Guilherme Rambo. All rights reserved. +// + +import UIKit + +class ViewController: UIViewController { + + private lazy var sheetContentController: UIViewController = { + let c = UIViewController() + + c.view.backgroundColor = .red + + return c + }() + + private lazy var sheetPresenter = SheetPresenter() + + @IBAction func showSheet(_ sender: UIButton) { + sheetPresenter.presentSheet(from: self, with: sheetContentController) + } + +} +