Initial commit

This commit is contained in:
Drew Olbrich
2019-02-03 12:22:40 -08:00
commit 64e4ae6a9c
99 changed files with 17449 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
# 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
*.xccheckout
*.xcscmblueprint
## Obj-C/Swift specific
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
.build/
# 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://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
+5
View File
@@ -0,0 +1,5 @@
language: swift
osx_image: xcode10.1
xcode_project: ScrollingContentViewController.xcodeproj
xcode_scheme: ScrollingContentViewController
xcode_destination: platform=iOS Simulator,OS=12.1,name=iPhone X
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

+30
View File
@@ -0,0 +1,30 @@
//
// AppDelegate.swift
// CodeExample
//
// Created by Drew Olbrich on 12/29/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
self.window = window
let signUpViewController = SignUpViewController()
window.rootViewController = signUpViewController
window.makeKeyAndVisible()
return true
}
}
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,183 @@
//
// SignUpViewController.swift
// CodeExample
//
// Created by Drew Olbrich on 12/29/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
import ScrollingContentViewController
/// A class that demonstrates configuring `ScrollingContentViewController`
/// programmatically. This view controller is instantiated in `AppDelegate` and
/// installed as the window's root view controller.
class SignUpViewController: ScrollingContentViewController {
/// Helper object that encapsulates code common to all
/// `ScrollingContentViewController` example applications.
private var signUpController: SignUpController?
private let logoImageView = UIImageView(image: UIImage(named: "Lorem-Ipsum-Logo"))
private let nameTextField = PillTextField()
private let emailTextField = PillTextField()
private let passwordTextField = PillTextField()
private let signUpButton = PillButton(type: .system)
private let signInButton = UIButton(type: .system)
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func loadView() {
// Assign a gradient as the root view.
view = GradientBackgroundView()
}
override func viewDidLoad() {
super.viewDidLoad()
// Create the content view.
createContentView()
// Allow the content view to shrink vertically when the keyboard is presented.
shouldResizeContentViewForKeyboard = true
// Allow the user to dismiss the keyboard by dragging from the scroll view to the
// bottom of the screen.
scrollView.keyboardDismissMode = .interactive
signUpController = SignUpController(logoImageView: logoImageView, nameTextField: nameTextField, emailTextField: emailTextField, passwordTextField: passwordTextField, signUpButton: signUpButton, signInButton: signInButton, delegate: self)
}
/// Creates the content view.
private func createContentView() {
// When ScrollingContentViewController.contentView is assigned for the first time,
// this has the side effect of adding a scroll view to the view controller's root
// view, and adding the content view to the scroll view. If a new view was assigned
// to this property later, it would replace the existing content view in the scroll
// view, and leave the scroll view unchanged.
contentView = UIView()
// Assign the content view's background color to transparent so it can be seen
// through it to the gradient background view. This is the default value, but the
// intent here is to be explicit for example code.
contentView.backgroundColor = nil
logoImageView.tintColor = .white
configureTextFields()
signUpButton.setTitle("Sign Up", for: .normal)
addConstraints()
}
private func configureTextFields() {
configureTextField(nameTextField, placeholder: "Name", textContentType: .name, autocapitalizationType: .words, keyboardType: .default, isSecureTextEntry: false)
configureTextField(emailTextField, placeholder: "Email", textContentType: .emailAddress, autocapitalizationType: .none, keyboardType: .emailAddress, isSecureTextEntry: false)
configureTextField(passwordTextField, placeholder: "Password", textContentType: nil, autocapitalizationType: .none, keyboardType: .default, isSecureTextEntry: true)
}
private func configureTextField(_ textField: UITextField, placeholder: String?, textContentType: UITextContentType?, autocapitalizationType: UITextAutocapitalizationType, keyboardType: UIKeyboardType, isSecureTextEntry: Bool) {
textField.placeholder = placeholder
textField.autocapitalizationType = autocapitalizationType
textField.autocorrectionType = .no
textField.smartDashesType = .no
textField.smartInsertDeleteType = .no
textField.smartQuotesType = .no
textField.spellCheckingType = .no
textField.returnKeyType = .next
textField.keyboardType = keyboardType
textField.enablesReturnKeyAutomatically = true
textField.isSecureTextEntry = isSecureTextEntry
}
private func addConstraints() {
logoImageView.translatesAutoresizingMaskIntoConstraints = false
nameTextField.translatesAutoresizingMaskIntoConstraints = false
emailTextField.translatesAutoresizingMaskIntoConstraints = false
passwordTextField.translatesAutoresizingMaskIntoConstraints = false
signUpButton.translatesAutoresizingMaskIntoConstraints = false
signInButton.translatesAutoresizingMaskIntoConstraints = false
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 16
stackView.addArrangedSubview(nameTextField)
stackView.addArrangedSubview(emailTextField)
stackView.addArrangedSubview(passwordTextField)
addPillViewConstraints(to: nameTextField)
addPillViewConstraints(to: emailTextField)
addPillViewConstraints(to: passwordTextField)
addPillViewConstraints(to: signUpButton)
contentView.addSubview(logoImageView)
contentView.addSubview(stackView)
contentView.addSubview(signUpButton)
contentView.addSubview(signInButton)
let logoImageTopLayoutGuide = UILayoutGuide()
let logoImageBottomLayoutGuide = UILayoutGuide()
let signUpButtonBottomLayoutGuide = UILayoutGuide()
contentView.addLayoutGuide(logoImageTopLayoutGuide)
contentView.addLayoutGuide(logoImageBottomLayoutGuide)
contentView.addLayoutGuide(signUpButtonBottomLayoutGuide)
let constraints: [NSLayoutConstraint] = [
contentView.centerXAnchor.constraint(equalTo: logoImageView.centerXAnchor),
contentView.centerXAnchor.constraint(equalTo: stackView.centerXAnchor),
contentView.centerXAnchor.constraint(equalTo: signUpButton.centerXAnchor),
contentView.centerXAnchor.constraint(equalTo: signInButton.centerXAnchor),
contentView.topAnchor.constraint(equalTo: logoImageTopLayoutGuide.topAnchor),
logoImageView.topAnchor.constraint(equalTo: logoImageTopLayoutGuide.bottomAnchor),
logoImageView.bottomAnchor.constraint(equalTo: logoImageBottomLayoutGuide.topAnchor),
stackView.topAnchor.constraint(equalTo: logoImageBottomLayoutGuide.bottomAnchor),
signUpButton.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 48),
signUpButton.bottomAnchor.constraint(equalTo: signUpButtonBottomLayoutGuide.topAnchor),
signInButton.topAnchor.constraint(equalTo: signUpButtonBottomLayoutGuide.bottomAnchor),
contentView.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: signInButton.bottomAnchor, constant: 30),
logoImageTopLayoutGuide.heightAnchor.constraint(equalTo: logoImageBottomLayoutGuide.heightAnchor),
logoImageBottomLayoutGuide.heightAnchor.constraint(equalTo: signUpButtonBottomLayoutGuide.heightAnchor, multiplier: 2, constant: 0),
signUpButtonBottomLayoutGuide.heightAnchor.constraint(greaterThanOrEqualToConstant: 16),
]
logoImageView.setContentHuggingPriority(.required, for: .vertical)
logoImageView.setContentHuggingPriority(.required, for: .horizontal)
contentView.addConstraints(constraints)
}
private func addPillViewConstraints(to pillView: UIView) {
let widthConstraint = pillView.widthAnchor.constraint(equalToConstant: 280)
widthConstraint.priority = UILayoutPriority.defaultLow - 10
let heightConstraint = pillView.heightAnchor.constraint(equalToConstant: 44)
heightConstraint.priority = .required
pillView.addConstraints([widthConstraint, heightConstraint])
}
}
extension SignUpViewController: SignUpControllerDelegate {
func signUpControllerScrollFirstResponderToVisible(_ signUpController: SignUpController) {
scrollView.scrollFirstResponderToVisible(animated: true)
}
}
+23
View File
@@ -0,0 +1,23 @@
//
// AppDelegate.swift
// ManagerExample
//
// Created by Drew Olbrich on 1/10/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
}
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,98 @@
//
// SignUpViewController.swift
// ManagerExample
//
// Created by Drew Olbrich on 1/10/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
import ScrollingContentViewController
/// A class that demonstrates using `ScrollingContentViewManager` in conjunction
/// with an arbitrary `UIViewController` class instead of subclassing
/// `ScrollingContentViewController`.
class SignUpViewController: UIViewController {
private lazy var scrollingContentViewManager = ScrollingContentViewManager(hostViewController: self)
/// Helper object that encapsulates code common to all
/// `ScrollingContentViewController` example applications.
private var signUpController: SignUpController?
@IBOutlet weak var contentView: UIView!
@IBOutlet weak var logoImageView: UIImageView!
@IBOutlet weak var nameTextField: PillTextField!
@IBOutlet weak var emailTextField: PillTextField!
@IBOutlet weak var passwordTextField: PillTextField!
@IBOutlet weak var signUpButton: PillButton!
@IBOutlet weak var signInButton: UIButton!
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func loadView() {
// Load all controls and connect all outlets defined by Interface Builder.
super.loadView()
scrollingContentViewManager.loadView(forContentView: contentView)
// Replace the root view with a gradient view.
view = GradientBackgroundView()
}
override func viewDidLoad() {
super.viewDidLoad()
// Set the content view's background color to transparent so the gradient
// background root view can be seen behind it.
contentView.backgroundColor = nil
// When ScrollingContentViewManager.contentView is first assigned, this has the
// side effect of adding a scroll view to the view controller's root view, and
// adding the content view to the scroll view. If a new view was assigned to this
// property later, it would replace the existing content view in the scroll view
// and leave the scroll view unchanged.
scrollingContentViewManager.contentView = contentView
// Allow the content view to shrink vertically when the keyboard is presented.
scrollingContentViewManager.shouldResizeContentViewForKeyboard = true
// Allow the user to dismiss the keyboard by dragging from the scroll view to the
// bottom of the screen.
scrollingContentViewManager.scrollView.keyboardDismissMode = .interactive
signUpController = SignUpController(logoImageView: logoImageView, nameTextField: nameTextField, emailTextField: emailTextField, passwordTextField: passwordTextField, signUpButton: signUpButton, signInButton: signInButton, delegate: self)
}
// Note: This is only required in apps that support device orientation changes.
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
scrollingContentViewManager.viewWillTransition(to: size, with: coordinator)
}
/// Note: This is only required in apps with navigation controllers that are used to
/// push sequences of view controllers with text fields that become the first
/// responder in `viewWillAppear`.
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
scrollingContentViewManager.viewSafeAreaInsetsDidChange()
}
}
extension SignUpViewController: SignUpControllerDelegate {
func signUpControllerScrollFirstResponderToVisible(_ signUpController: SignUpController) {
scrollingContentViewManager.scrollView.scrollFirstResponderToVisible(animated: true)
}
}
@@ -0,0 +1,22 @@
//
// AppDelegate.swift
// ReassignExample
//
// Created by Drew Olbrich on 2/2/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>
@@ -0,0 +1,223 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="H0g-Eg-A5o">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Example-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="ViewController" customModule="ReassignExample" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
<toolbarItems>
<barButtonItem style="plain" systemItem="flexibleSpace" id="taj-jd-l9k"/>
<barButtonItem title="Toggle Content View" id="ob5-bQ-ah2">
<connections>
<action selector="toggleContentView:" destination="BYZ-38-t0r" id="6UI-rJ-ytW"/>
</connections>
</barButtonItem>
<barButtonItem style="plain" systemItem="flexibleSpace" id="0Hz-hZ-fdB"/>
</toolbarItems>
<navigationItem key="navigationItem" title="Example" id="nxq-7u-d4y"/>
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>
<connections>
<outlet property="contentView" destination="j4q-qS-501" id="8Gm-Jb-01d"/>
<outlet property="firstContentView" destination="j4q-qS-501" id="iKt-0R-z5m"/>
<outlet property="secondContentView" destination="Xy7-65-ZKc" id="Zva-vw-Trz"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
<view contentMode="scaleToFill" id="j4q-qS-501" customClass="FixedHeightContentView" customModule="ReassignExample" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="460"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="First Content View" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lNT-9J-Adj">
<rect key="frame" x="90" y="220" width="140.5" height="20"/>
<color key="backgroundColor" red="1" green="0.93794201450000003" blue="0.75245949069999996" alpha="1" colorSpace="calibratedRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Content view bottom" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ndh-eg-7jr">
<rect key="frame" x="96.5" y="434" width="127" height="16"/>
<color key="backgroundColor" red="1" green="0.93794201450000003" blue="0.75245949069999996" alpha="1" colorSpace="calibratedRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="bw3-NX-k9m">
<rect key="frame" x="48" y="424" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="pto-2q-wBA">
<rect key="frame" x="48" y="4" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="S9C-6b-oTu">
<rect key="frame" x="240" y="4" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Content view top" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pTe-MB-xgt">
<rect key="frame" x="108.5" y="10" width="103.5" height="16"/>
<color key="backgroundColor" red="1" green="0.93794201450000003" blue="0.75245949069999996" alpha="1" colorSpace="calibratedRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="UGU-OP-WLW">
<rect key="frame" x="240" y="424" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="KCB-dI-fE3" firstAttribute="bottom" secondItem="UGU-OP-WLW" secondAttribute="bottom" constant="4" id="48r-lm-PEd"/>
<constraint firstItem="bw3-NX-k9m" firstAttribute="leading" secondItem="KCB-dI-fE3" secondAttribute="leading" constant="48" id="5uk-N2-dlJ"/>
<constraint firstItem="pTe-MB-xgt" firstAttribute="centerX" secondItem="KCB-dI-fE3" secondAttribute="centerX" id="8HV-z6-QzA"/>
<constraint firstItem="lNT-9J-Adj" firstAttribute="centerY" secondItem="j4q-qS-501" secondAttribute="centerY" id="Bu6-7T-UGO"/>
<constraint firstItem="KCB-dI-fE3" firstAttribute="bottom" secondItem="ndh-eg-7jr" secondAttribute="bottom" constant="10" id="Nva-tp-ylD"/>
<constraint firstItem="S9C-6b-oTu" firstAttribute="centerX" secondItem="UGU-OP-WLW" secondAttribute="centerX" id="RhF-AW-6DY"/>
<constraint firstItem="KCB-dI-fE3" firstAttribute="bottom" secondItem="bw3-NX-k9m" secondAttribute="bottom" constant="4" id="TQd-fX-T9t"/>
<constraint firstItem="pTe-MB-xgt" firstAttribute="top" secondItem="KCB-dI-fE3" secondAttribute="top" constant="10" id="XPf-2I-1ET"/>
<constraint firstItem="S9C-6b-oTu" firstAttribute="top" secondItem="KCB-dI-fE3" secondAttribute="top" constant="4" id="ZJI-Hg-dGl"/>
<constraint firstItem="KCB-dI-fE3" firstAttribute="trailing" secondItem="UGU-OP-WLW" secondAttribute="trailing" constant="48" id="gLK-kM-XWh"/>
<constraint firstItem="pto-2q-wBA" firstAttribute="top" secondItem="KCB-dI-fE3" secondAttribute="top" constant="4" id="hgP-9O-QE9"/>
<constraint firstItem="lNT-9J-Adj" firstAttribute="centerX" secondItem="j4q-qS-501" secondAttribute="centerX" id="khE-y4-3kf"/>
<constraint firstItem="ndh-eg-7jr" firstAttribute="centerX" secondItem="KCB-dI-fE3" secondAttribute="centerX" id="tKr-bx-cRc"/>
<constraint firstItem="pto-2q-wBA" firstAttribute="centerX" secondItem="bw3-NX-k9m" secondAttribute="centerX" id="v06-ql-ZBV"/>
</constraints>
<viewLayoutGuide key="safeArea" id="KCB-dI-fE3"/>
</view>
<view contentMode="scaleToFill" id="Xy7-65-ZKc" customClass="FixedHeightContentView" customModule="ReassignExample" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="320"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Second Content View" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7Jt-Fy-vsF">
<rect key="frame" x="77" y="149.5" width="166" height="21"/>
<color key="backgroundColor" red="1" green="0.93794201450000003" blue="0.75245949069999996" alpha="1" colorSpace="calibratedRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Content view bottom" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zJi-zF-T4s">
<rect key="frame" x="96.5" y="294" width="127" height="16"/>
<color key="backgroundColor" red="1" green="0.93794201450000003" blue="0.75245949069999996" alpha="1" colorSpace="calibratedRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="JmW-fh-ysI">
<rect key="frame" x="48" y="284" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="E9d-li-VrP">
<rect key="frame" x="240" y="284" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="7Xb-oK-fIW">
<rect key="frame" x="48" y="4" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="0lQ-Ep-Y6W">
<rect key="frame" x="240" y="4" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Content view top" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vxN-mT-Cv7">
<rect key="frame" x="108.5" y="10" width="103.5" height="16"/>
<color key="backgroundColor" red="1" green="0.93794201450000003" blue="0.75245949069999996" alpha="1" colorSpace="calibratedRGB"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="7Jt-Fy-vsF" firstAttribute="centerX" secondItem="Xy7-65-ZKc" secondAttribute="centerX" id="0xa-IU-t7G"/>
<constraint firstItem="vxN-mT-Cv7" firstAttribute="centerX" secondItem="GJG-wJ-dSY" secondAttribute="centerX" id="0zi-Q1-TRL"/>
<constraint firstItem="GJG-wJ-dSY" firstAttribute="bottom" secondItem="zJi-zF-T4s" secondAttribute="bottom" constant="10" id="3uL-aZ-JoD"/>
<constraint firstItem="7Jt-Fy-vsF" firstAttribute="centerY" secondItem="Xy7-65-ZKc" secondAttribute="centerY" id="7Gy-Eg-ndf"/>
<constraint firstItem="0lQ-Ep-Y6W" firstAttribute="centerX" secondItem="E9d-li-VrP" secondAttribute="centerX" id="H7x-L5-ldv"/>
<constraint firstItem="GJG-wJ-dSY" firstAttribute="bottom" secondItem="E9d-li-VrP" secondAttribute="bottom" constant="4" id="Pja-t5-5oz"/>
<constraint firstItem="GJG-wJ-dSY" firstAttribute="trailing" secondItem="E9d-li-VrP" secondAttribute="trailing" constant="48" id="QtL-ip-MUO"/>
<constraint firstItem="7Xb-oK-fIW" firstAttribute="top" secondItem="GJG-wJ-dSY" secondAttribute="top" constant="4" id="SfF-wz-j0t"/>
<constraint firstItem="7Xb-oK-fIW" firstAttribute="centerX" secondItem="JmW-fh-ysI" secondAttribute="centerX" id="cOB-Oe-Zhd"/>
<constraint firstItem="zJi-zF-T4s" firstAttribute="centerX" secondItem="GJG-wJ-dSY" secondAttribute="centerX" id="dSD-OJ-l28"/>
<constraint firstItem="GJG-wJ-dSY" firstAttribute="bottom" secondItem="JmW-fh-ysI" secondAttribute="bottom" constant="4" id="nU6-Kc-ET6"/>
<constraint firstItem="vxN-mT-Cv7" firstAttribute="top" secondItem="GJG-wJ-dSY" secondAttribute="top" constant="10" id="oez-81-iVS"/>
<constraint firstItem="0lQ-Ep-Y6W" firstAttribute="top" secondItem="GJG-wJ-dSY" secondAttribute="top" constant="4" id="ooI-ti-Wgk"/>
<constraint firstItem="JmW-fh-ysI" firstAttribute="leading" secondItem="GJG-wJ-dSY" secondAttribute="leading" constant="48" id="zJv-43-Fk1"/>
</constraints>
<viewLayoutGuide key="safeArea" id="GJG-wJ-dSY"/>
</view>
</objects>
<point key="canvasLocation" x="65" y="133"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="C7j-Uq-b19">
<objects>
<navigationController toolbarHidden="NO" id="H0g-Eg-A5o" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="jNo-Kz-scI">
<rect key="frame" x="0.0" y="20" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<toolbar key="toolbar" opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="Pg1-Oy-eXx">
<rect key="frame" x="0.0" y="623" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</toolbar>
<connections>
<segue destination="BYZ-38-t0r" kind="relationship" relationship="rootViewController" id="pf1-zv-HWq"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="euT-D1-2Lz" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-876" y="133"/>
</scene>
</scenes>
<resources>
<image name="Bottom-Edge" width="32" height="32"/>
<image name="Top-Edge" width="32" height="32"/>
</resources>
</document>
@@ -0,0 +1,34 @@
//
// FixedHeightContentView.swift
// ReassignExample
//
// Created by Drew Olbrich on 2/2/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A content view that will have a fixed height defined using `intrinsicContentSize`
/// and a vertical content hugging priority of `required`.
class FixedHeightContentView: UIView {
/// The desired height of the content view.
var height: CGFloat = 0
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
// Increase the vertical content hugging priority so the view won't grow beyond its
// specified size. Without this assignment, the vertical content hugging priority
// would be defaultLow and the view would grow to fit the available height of the
// scroll view.
setContentHuggingPriority(.required, for: .vertical)
}
override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: height)
}
}
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,38 @@
//
// ViewController.swift
// ReassignExample
//
// Created by Drew Olbrich on 2/2/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
import ScrollingContentViewController
/// A class that demonstrates dynamically reassigning the `contentView` property of
/// a `ScrollingContentViewController`.
class ViewController: ScrollingContentViewController {
@IBOutlet var firstContentView: FixedHeightContentView!
@IBOutlet var secondContentView: FixedHeightContentView!
override func viewDidLoad() {
super.viewDidLoad()
firstContentView.height = 568
secondContentView.height = 320
view.backgroundColor = UIColor.init(white: 0.94, alpha: 1)
}
@IBAction func toggleContentView(_ sender: Any) {
if contentView == firstContentView {
contentView = secondContentView
} else {
contentView = firstContentView
}
}
}
@@ -0,0 +1,22 @@
//
// AppDelegate.swift
// SequenceExample
//
// Created by Drew Olbrich on 1/12/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
}
@@ -0,0 +1,328 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="6jx-ov-y2X">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Navigation Controller-->
<scene sceneID="mqc-0b-pWp">
<objects>
<navigationController id="6jx-ov-y2X" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="Ie8-Ol-83h">
<rect key="frame" x="0.0" y="20" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="vIC-bo-UGc" kind="relationship" relationship="rootViewController" id="Ew5-Ip-rNK"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="A88-Nn-AUB" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-394" y="143"/>
</scene>
<!--First-->
<scene sceneID="cfy-Wz-nA0">
<objects>
<viewController id="vIC-bo-UGc" customClass="FirstViewController" customModule="SequenceExample" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="N4P-eb-Rwh">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="8XX-iU-ry4">
<rect key="frame" x="47.5" y="128" width="280" height="30"/>
<constraints>
<constraint firstAttribute="width" priority="260" constant="280" id="C3T-U2-c38"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" returnKeyType="next"/>
</textField>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Safe area bottom" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="l90-Ko-Sva">
<rect key="frame" x="135.5" y="641" width="104" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="KfU-H7-abq">
<rect key="frame" x="64" y="631" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="bzj-fB-6bH">
<rect key="frame" x="279" y="631" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="XZG-4Q-u3v">
<rect key="frame" x="64" y="68" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="zze-8V-8dj">
<rect key="frame" x="279" y="68" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Safe area top" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Rxc-vh-B3h">
<rect key="frame" x="147" y="74" width="81" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="8XX-iU-ry4" firstAttribute="centerX" secondItem="W2y-Yf-pkH" secondAttribute="centerX" id="4w7-oS-E0n"/>
<constraint firstItem="W2y-Yf-pkH" firstAttribute="bottom" secondItem="bzj-fB-6bH" secondAttribute="bottom" constant="4" id="5bz-61-Ygd"/>
<constraint firstItem="W2y-Yf-pkH" firstAttribute="bottom" secondItem="KfU-H7-abq" secondAttribute="bottom" constant="4" id="63R-M7-VwK"/>
<constraint firstItem="KfU-H7-abq" firstAttribute="leading" secondItem="W2y-Yf-pkH" secondAttribute="leading" constant="64" id="75S-DK-AAJ"/>
<constraint firstItem="zze-8V-8dj" firstAttribute="centerX" secondItem="bzj-fB-6bH" secondAttribute="centerX" id="7Kv-g5-hfx"/>
<constraint firstItem="W2y-Yf-pkH" firstAttribute="trailing" secondItem="bzj-fB-6bH" secondAttribute="trailing" constant="64" id="7xz-Er-Fxn"/>
<constraint firstItem="W2y-Yf-pkH" firstAttribute="bottom" secondItem="l90-Ko-Sva" secondAttribute="bottom" constant="10" id="8n4-XS-3gn"/>
<constraint firstItem="l90-Ko-Sva" firstAttribute="centerX" secondItem="W2y-Yf-pkH" secondAttribute="centerX" id="8vu-lZ-bs4"/>
<constraint firstItem="Rxc-vh-B3h" firstAttribute="top" secondItem="W2y-Yf-pkH" secondAttribute="top" constant="10" id="ANr-VK-1rm"/>
<constraint firstItem="8XX-iU-ry4" firstAttribute="top" secondItem="W2y-Yf-pkH" secondAttribute="top" constant="64" id="LYH-56-I5x"/>
<constraint firstItem="XZG-4Q-u3v" firstAttribute="centerX" secondItem="KfU-H7-abq" secondAttribute="centerX" id="SzH-8t-76k"/>
<constraint firstItem="zze-8V-8dj" firstAttribute="top" secondItem="W2y-Yf-pkH" secondAttribute="top" constant="4" id="b8n-pi-8i8"/>
<constraint firstItem="W2y-Yf-pkH" firstAttribute="bottom" secondItem="8XX-iU-ry4" secondAttribute="bottom" priority="240" constant="64" id="hdX-Pf-vnN"/>
<constraint firstItem="Rxc-vh-B3h" firstAttribute="centerX" secondItem="W2y-Yf-pkH" secondAttribute="centerX" id="qip-Yb-nOz"/>
<constraint firstItem="XZG-4Q-u3v" firstAttribute="top" secondItem="W2y-Yf-pkH" secondAttribute="top" constant="4" id="zzs-Yd-fzW"/>
</constraints>
<viewLayoutGuide key="safeArea" id="W2y-Yf-pkH"/>
</view>
<navigationItem key="navigationItem" title="First" id="gD8-4J-Upa">
<barButtonItem key="rightBarButtonItem" title="Next" id="Ial-Tu-dN8">
<connections>
<segue destination="pCc-fQ-Hda" kind="show" identifier="next" id="3lt-ml-bIy"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="contentView" destination="N4P-eb-Rwh" id="L2F-07-38v"/>
<outlet property="textField" destination="8XX-iU-ry4" id="U6q-be-KJt"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="4Rq-k3-lAh" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="465" y="143"/>
</scene>
<!--Second-->
<scene sceneID="Kpe-N1-k1t">
<objects>
<viewController id="pCc-fQ-Hda" customClass="SecondViewController" customModule="SequenceExample" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="1JR-jx-8jB">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="mEM-VS-KHt">
<rect key="frame" x="47.5" y="128" width="280" height="30"/>
<constraints>
<constraint firstAttribute="width" priority="260" constant="280" id="65G-Kd-aIC"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" returnKeyType="next"/>
</textField>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Safe area bottom" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0OB-Q9-PDT">
<rect key="frame" x="135.5" y="641" width="104" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="obU-tq-pz6">
<rect key="frame" x="279" y="631" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="eOK-36-g6j">
<rect key="frame" x="64" y="631" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="nnj-Pr-jUA">
<rect key="frame" x="64" y="68" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="niV-1Q-KSZ">
<rect key="frame" x="279" y="68" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Safe area top" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Jgn-lv-7ej">
<rect key="frame" x="147" y="74" width="81" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="2tf-pV-SOj" firstAttribute="bottom" secondItem="mEM-VS-KHt" secondAttribute="bottom" priority="240" constant="64" id="8cG-eZ-lwz"/>
<constraint firstItem="nnj-Pr-jUA" firstAttribute="centerX" secondItem="eOK-36-g6j" secondAttribute="centerX" id="JJu-Bo-mAF"/>
<constraint firstItem="2tf-pV-SOj" firstAttribute="bottom" secondItem="eOK-36-g6j" secondAttribute="bottom" constant="4" id="JRx-2A-SCV"/>
<constraint firstItem="nnj-Pr-jUA" firstAttribute="top" secondItem="2tf-pV-SOj" secondAttribute="top" constant="4" id="LwZ-0P-mfV"/>
<constraint firstItem="Jgn-lv-7ej" firstAttribute="centerX" secondItem="2tf-pV-SOj" secondAttribute="centerX" id="Mqm-Wg-9iG"/>
<constraint firstItem="mEM-VS-KHt" firstAttribute="centerX" secondItem="2tf-pV-SOj" secondAttribute="centerX" id="UV1-WX-Cyy"/>
<constraint firstItem="niV-1Q-KSZ" firstAttribute="centerX" secondItem="obU-tq-pz6" secondAttribute="centerX" id="aKl-Vt-G9f"/>
<constraint firstItem="0OB-Q9-PDT" firstAttribute="centerX" secondItem="2tf-pV-SOj" secondAttribute="centerX" id="bkr-IH-wqK"/>
<constraint firstItem="2tf-pV-SOj" firstAttribute="bottom" secondItem="0OB-Q9-PDT" secondAttribute="bottom" constant="10" id="cHB-Mo-deI"/>
<constraint firstItem="eOK-36-g6j" firstAttribute="leading" secondItem="2tf-pV-SOj" secondAttribute="leading" constant="64" id="dEP-cg-BUM"/>
<constraint firstItem="niV-1Q-KSZ" firstAttribute="top" secondItem="2tf-pV-SOj" secondAttribute="top" constant="4" id="gVz-pi-QLK"/>
<constraint firstItem="mEM-VS-KHt" firstAttribute="top" secondItem="2tf-pV-SOj" secondAttribute="top" constant="64" id="iyG-NL-o56"/>
<constraint firstItem="2tf-pV-SOj" firstAttribute="bottom" secondItem="obU-tq-pz6" secondAttribute="bottom" constant="4" id="kdd-hB-pfe"/>
<constraint firstItem="Jgn-lv-7ej" firstAttribute="top" secondItem="2tf-pV-SOj" secondAttribute="top" constant="10" id="oed-6A-eQd"/>
<constraint firstItem="2tf-pV-SOj" firstAttribute="trailing" secondItem="obU-tq-pz6" secondAttribute="trailing" constant="64" id="vre-SH-oO8"/>
</constraints>
<viewLayoutGuide key="safeArea" id="2tf-pV-SOj"/>
</view>
<navigationItem key="navigationItem" title="Second" id="xeu-MQ-49z">
<barButtonItem key="rightBarButtonItem" title="Next" id="IVC-Qj-8Mr">
<connections>
<segue destination="OrV-46-rxy" kind="show" identifier="next" id="DJB-z1-9Y2"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="contentView" destination="1JR-jx-8jB" id="fjU-ka-uHj"/>
<outlet property="textField" destination="mEM-VS-KHt" id="W7O-Ts-6f6"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="YEP-Ri-ZhU" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1316" y="143"/>
</scene>
<!--Third-->
<scene sceneID="JV5-Pt-6nI">
<objects>
<viewController id="OrV-46-rxy" customClass="ThirdViewController" customModule="SequenceExample" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="oqp-vz-4pF">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="smT-RC-FvD">
<rect key="frame" x="47.5" y="128" width="280" height="30"/>
<constraints>
<constraint firstAttribute="width" priority="260" constant="280" id="suP-3m-O8o"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" returnKeyType="done"/>
</textField>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Safe area bottom" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SrI-wU-jHe">
<rect key="frame" x="135.5" y="641" width="104" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="zET-AE-FtO">
<rect key="frame" x="64" y="631" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Bottom-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="PKy-vX-Ecq">
<rect key="frame" x="279" y="631" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="Stu-io-46A">
<rect key="frame" x="64" y="68" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<imageView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Top-Edge" translatesAutoresizingMaskIntoConstraints="NO" id="iar-Iy-tEg">
<rect key="frame" x="279" y="68" width="32" height="32"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="tintColor">
<color key="value" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" text="Safe area top" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sEa-2c-dGx">
<rect key="frame" x="147" y="74" width="81" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="smT-RC-FvD" firstAttribute="centerX" secondItem="EMB-Jf-CUX" secondAttribute="centerX" id="3HB-WV-LEG"/>
<constraint firstItem="EMB-Jf-CUX" firstAttribute="bottom" secondItem="smT-RC-FvD" secondAttribute="bottom" priority="240" constant="64" id="4ET-TW-Ldk"/>
<constraint firstItem="EMB-Jf-CUX" firstAttribute="bottom" secondItem="zET-AE-FtO" secondAttribute="bottom" constant="4" id="9Mf-wL-edR"/>
<constraint firstItem="iar-Iy-tEg" firstAttribute="top" secondItem="EMB-Jf-CUX" secondAttribute="top" constant="4" id="DXa-Z1-xJN"/>
<constraint firstItem="Stu-io-46A" firstAttribute="centerX" secondItem="zET-AE-FtO" secondAttribute="centerX" id="HWk-7V-9Op"/>
<constraint firstItem="EMB-Jf-CUX" firstAttribute="trailing" secondItem="PKy-vX-Ecq" secondAttribute="trailing" constant="64" id="KQs-yi-bKm"/>
<constraint firstItem="Stu-io-46A" firstAttribute="top" secondItem="EMB-Jf-CUX" secondAttribute="top" constant="4" id="L8F-xK-cab"/>
<constraint firstItem="iar-Iy-tEg" firstAttribute="centerX" secondItem="PKy-vX-Ecq" secondAttribute="centerX" id="RC1-Ij-JxC"/>
<constraint firstItem="zET-AE-FtO" firstAttribute="leading" secondItem="EMB-Jf-CUX" secondAttribute="leading" constant="64" id="RKh-gR-KCf"/>
<constraint firstItem="sEa-2c-dGx" firstAttribute="centerX" secondItem="EMB-Jf-CUX" secondAttribute="centerX" id="U90-Zd-UcV"/>
<constraint firstItem="smT-RC-FvD" firstAttribute="top" secondItem="EMB-Jf-CUX" secondAttribute="top" constant="64" id="UMc-0f-M4w"/>
<constraint firstItem="SrI-wU-jHe" firstAttribute="centerX" secondItem="EMB-Jf-CUX" secondAttribute="centerX" id="ebO-LO-wCt"/>
<constraint firstItem="EMB-Jf-CUX" firstAttribute="bottom" secondItem="SrI-wU-jHe" secondAttribute="bottom" constant="10" id="jed-2Q-bQC"/>
<constraint firstItem="EMB-Jf-CUX" firstAttribute="bottom" secondItem="PKy-vX-Ecq" secondAttribute="bottom" constant="4" id="pvw-5r-Xct"/>
<constraint firstItem="sEa-2c-dGx" firstAttribute="top" secondItem="EMB-Jf-CUX" secondAttribute="top" constant="10" id="rzt-O9-TkL"/>
</constraints>
<viewLayoutGuide key="safeArea" id="EMB-Jf-CUX"/>
</view>
<navigationItem key="navigationItem" title="Third" id="EsG-Cm-I1K">
<barButtonItem key="rightBarButtonItem" style="done" systemItem="done" id="LjW-pd-wsp">
<connections>
<action selector="dismissKeyboard:" destination="OrV-46-rxy" id="OTS-gm-8YG"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="contentView" destination="oqp-vz-4pF" id="j7H-qu-nCt"/>
<outlet property="textField" destination="smT-RC-FvD" id="6Vw-P0-dj7"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="rSf-ci-rzN" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2168.8000000000002" y="142.57871064467767"/>
</scene>
</scenes>
<resources>
<image name="Bottom-Edge" width="32" height="32"/>
<image name="Top-Edge" width="32" height="32"/>
</resources>
</document>
@@ -0,0 +1,23 @@
//
// FirstViewController.swift
// SequenceExample
//
// Created by Drew Olbrich on 1/12/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
class FirstViewController: SequenceViewController {
override var shouldAssignFirstResponder: Bool {
return true
}
override func didTapReturnKey() {
performSegue(withIdentifier: "next", sender: nil)
}
}
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>
@@ -0,0 +1,23 @@
//
// SecondViewController.swift
// SequenceExample
//
// Created by Drew Olbrich on 1/13/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
class SecondViewController: SequenceViewController {
override var shouldAssignFirstResponder: Bool {
return true
}
override func didTapReturnKey() {
performSegue(withIdentifier: "next", sender: nil)
}
}
@@ -0,0 +1,63 @@
//
// SequenceViewController.swift
// SequenceExample
//
// Created by Drew Olbrich on 1/13/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
import ScrollingContentViewController
class SequenceViewController: ScrollingContentViewController {
@IBOutlet weak var textField: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
// Allow the content view to shrink vertically when the keyboard is presented.
shouldResizeContentViewForKeyboard = true
// Allow the user to dismiss the keyboard by swiping down.
scrollView.keyboardDismissMode = .interactive
scrollView.alwaysBounceVertical = true
contentView.backgroundColor = UIColor.init(white: 0.9, alpha: 1)
textField.delegate = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if shouldAssignFirstResponder {
// When the view appears, make the text field the first responder.
// This causes the keyboard to be immediately presented.
textField?.becomeFirstResponder()
}
}
func didTapReturnKey() {
// Override this in subclasses.
}
/// If `true`, the `textField` outlet is assigned as the first responder when the
/// view appears, presenting the keyboard.
var shouldAssignFirstResponder: Bool {
return true
}
}
extension SequenceViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
didTapReturnKey()
return true
}
}
@@ -0,0 +1,27 @@
//
// ThirdViewController.swift
// SequenceExample
//
// Created by Drew Olbrich on 1/13/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
class ThirdViewController: SequenceViewController {
override var shouldAssignFirstResponder: Bool {
return true
}
override func didTapReturnKey() {
dismissKeyboard(self)
}
@IBAction func dismissKeyboard(_ sender: Any) {
view.window?.endEditing(true)
}
}
@@ -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"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "Lorem-Ipsum-Logo.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "Bottom-Edge.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "Top-Edge.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.14509803921568626" green="0.69019607843137254" blue="0.69019607843137254" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>
+135
View File
@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="h6e-Hp-WbF">
<device id="retina5_9" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Sign Up View Controller-->
<scene sceneID="ZD8-Yo-6dX">
<objects>
<viewController storyboardIdentifier="signUpViewController" id="h6e-Hp-WbF" customClass="SignUpViewController" customModule="ManagerExample" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="xsR-Qf-umG">
<rect key="frame" x="0.0" y="0.0" width="375" height="812"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="8BW-Aw-gvm" customClass="PillButton" customModule="ManagerExample" customModuleProvider="target">
<rect key="frame" x="47.666666666666657" y="610.33333333333337" width="280" height="44"/>
<constraints>
<constraint firstAttribute="height" constant="44" id="Gyb-S2-mnp"/>
<constraint firstAttribute="width" priority="260" constant="280" id="Xs5-Zt-QW1"/>
</constraints>
<state key="normal" title="Sign Up"/>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="IiB-zO-H3z">
<rect key="frame" x="74" y="718" width="227" height="30"/>
<state key="normal" title="Already have an account? Sign In"/>
</button>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Lorem-Ipsum-Logo" translatesAutoresizingMaskIntoConstraints="NO" id="ihH-xN-OTN">
<rect key="frame" x="90.666666666666686" y="171.33333333333334" width="194" height="100.00000000000003"/>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="OOh-bu-pjX">
<rect key="frame" x="47.666666666666657" y="398.33333333333331" width="280" height="163.99999999999994"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" placeholder="Name" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="y6x-Gv-wx5" customClass="PillTextField" customModule="ManagerExample" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="280" height="44"/>
<constraints>
<constraint firstAttribute="width" priority="260" constant="280" id="BJF-yq-pRQ"/>
<constraint firstAttribute="height" constant="44" id="b8h-HK-ye4"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" autocapitalizationType="words" autocorrectionType="no" spellCheckingType="no" returnKeyType="next" enablesReturnKeyAutomatically="YES" smartDashesType="no" smartInsertDeleteType="no" smartQuotesType="no" textContentType="name"/>
</textField>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" placeholder="Email" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="VoQ-gV-n0W" customClass="PillTextField" customModule="ManagerExample" customModuleProvider="target">
<rect key="frame" x="0.0" y="60" width="280" height="44"/>
<constraints>
<constraint firstAttribute="width" priority="260" constant="280" id="M2g-Kp-rnU"/>
<constraint firstAttribute="height" constant="44" id="sub-CU-G4J"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" autocorrectionType="no" spellCheckingType="no" keyboardType="emailAddress" returnKeyType="next" enablesReturnKeyAutomatically="YES" smartDashesType="no" smartInsertDeleteType="no" smartQuotesType="no" textContentType="email"/>
</textField>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" placeholder="Password" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="KHf-Ih-6Zy" customClass="PillTextField" customModule="ManagerExample" customModuleProvider="target">
<rect key="frame" x="0.0" y="120.00000000000006" width="280" height="44"/>
<constraints>
<constraint firstAttribute="height" constant="44" id="Apx-PC-aSj"/>
<constraint firstAttribute="width" priority="260" constant="280" id="cp5-zD-8lR"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" autocorrectionType="no" spellCheckingType="no" returnKeyType="next" enablesReturnKeyAutomatically="YES" secureTextEntry="YES" smartDashesType="no" smartInsertDeleteType="no" smartQuotesType="no"/>
</textField>
</subviews>
</stackView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jJx-RT-UM1">
<rect key="frame" x="167.66666666666666" y="271.33333333333331" width="40" height="127"/>
<color key="backgroundColor" red="1" green="0.49803921568627452" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
<constraints>
<constraint firstAttribute="width" constant="40" id="pMn-4j-PJP"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" placeholderIntrinsicWidth="40" placeholderIntrinsicHeight="69.333333333333314" translatesAutoresizingMaskIntoConstraints="NO" id="xuu-8i-Bcd">
<rect key="frame" x="167.66666666666666" y="43.999999999999993" width="40" height="127.33333333333331"/>
<color key="backgroundColor" red="1" green="0.49803921568627452" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
<constraints>
<constraint firstAttribute="width" constant="40" id="iLq-gl-q4v"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" placeholderIntrinsicWidth="40" placeholderIntrinsicHeight="34.666666666666629" translatesAutoresizingMaskIntoConstraints="NO" id="q4e-X2-l7i">
<rect key="frame" x="167.66666666666666" y="654.33333333333337" width="40" height="63.666666666666629"/>
<color key="backgroundColor" red="1" green="0.49803921568627452" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="16" id="DoC-fk-rsr"/>
<constraint firstAttribute="width" constant="40" id="OWB-yk-CUh"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="jJx-RT-UM1" firstAttribute="top" secondItem="ihH-xN-OTN" secondAttribute="bottom" constant="-5.6843418860808015e-14" id="0E9-BR-gjM"/>
<constraint firstItem="ihH-xN-OTN" firstAttribute="top" secondItem="xuu-8i-Bcd" secondAttribute="bottom" constant="5.6843418860808015e-14" id="6jB-PU-oTq"/>
<constraint firstItem="8BW-Aw-gvm" firstAttribute="centerX" secondItem="xsR-Qf-umG" secondAttribute="centerX" id="6on-tv-NuJ"/>
<constraint firstItem="jJx-RT-UM1" firstAttribute="height" secondItem="xuu-8i-Bcd" secondAttribute="height" id="BKE-gq-m5N"/>
<constraint firstItem="IiB-zO-H3z" firstAttribute="centerX" secondItem="xsR-Qf-umG" secondAttribute="centerX" id="Klo-uT-6bM"/>
<constraint firstItem="xuu-8i-Bcd" firstAttribute="top" secondItem="Q3Y-06-WiO" secondAttribute="top" id="Lhe-HE-Nms"/>
<constraint firstItem="q4e-X2-l7i" firstAttribute="centerX" secondItem="xsR-Qf-umG" secondAttribute="centerX" id="OTF-uU-SwS"/>
<constraint firstItem="OOh-bu-pjX" firstAttribute="top" secondItem="jJx-RT-UM1" secondAttribute="bottom" constant="-5.6843418860808015e-14" id="SfW-95-Fih"/>
<constraint firstItem="OOh-bu-pjX" firstAttribute="centerX" secondItem="xsR-Qf-umG" secondAttribute="centerX" id="WdA-rv-laX"/>
<constraint firstItem="IiB-zO-H3z" firstAttribute="top" secondItem="q4e-X2-l7i" secondAttribute="bottom" constant="-1.1368683772161603e-13" id="Xgw-rk-YyI"/>
<constraint firstItem="jJx-RT-UM1" firstAttribute="height" secondItem="q4e-X2-l7i" secondAttribute="height" multiplier="2" id="Xnj-ES-1B4"/>
<constraint firstItem="jJx-RT-UM1" firstAttribute="centerX" secondItem="xsR-Qf-umG" secondAttribute="centerX" id="bnh-5o-UVp"/>
<constraint firstItem="ihH-xN-OTN" firstAttribute="centerX" secondItem="xsR-Qf-umG" secondAttribute="centerX" id="crD-zx-gCd"/>
<constraint firstItem="xuu-8i-Bcd" firstAttribute="centerX" secondItem="xsR-Qf-umG" secondAttribute="centerX" id="grq-Hk-vCI"/>
<constraint firstItem="8BW-Aw-gvm" firstAttribute="top" secondItem="OOh-bu-pjX" secondAttribute="bottom" constant="48.000000000000114" id="kaF-12-7hf"/>
<constraint firstItem="Q3Y-06-WiO" firstAttribute="bottom" secondItem="IiB-zO-H3z" secondAttribute="bottom" constant="30" id="oCB-qb-91l"/>
<constraint firstItem="q4e-X2-l7i" firstAttribute="top" secondItem="8BW-Aw-gvm" secondAttribute="bottom" constant="1.1368683772161603e-13" id="oiK-ca-jHS"/>
<constraint firstItem="jJx-RT-UM1" firstAttribute="top" secondItem="ihH-xN-OTN" secondAttribute="bottom" constant="-5.6843418860808015e-14" id="tlv-NF-pv8"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Q3Y-06-WiO"/>
</view>
<connections>
<outlet property="contentView" destination="xsR-Qf-umG" id="PTL-C2-Yal"/>
<outlet property="emailTextField" destination="VoQ-gV-n0W" id="ASd-zO-cfh"/>
<outlet property="logoImageView" destination="ihH-xN-OTN" id="mrc-Mu-wo8"/>
<outlet property="nameTextField" destination="y6x-Gv-wx5" id="VeM-NE-baV"/>
<outlet property="passwordTextField" destination="KHf-Ih-6Zy" id="dLU-lW-rNY"/>
<outlet property="signInButton" destination="IiB-zO-H3z" id="mNI-Rq-c2M"/>
<outlet property="signUpButton" destination="8BW-Aw-gvm" id="a9b-Ve-qv6"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dgg-jS-z1L" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="864.79999999999995" y="133.74384236453201"/>
</scene>
</scenes>
<resources>
<image name="Lorem-Ipsum-Logo" width="194" height="100"/>
</resources>
</document>
@@ -0,0 +1,54 @@
//
// GradientBackgroundView.swift
// Examples
//
// Created by Drew Olbrich on 12/24/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A background view with a blue/green gradient.
class GradientBackgroundView: UIView {
override class var layerClass: AnyClass {
return CAGradientLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
commonInit()
}
private func commonInit() {
guard let gradientLayer = layer as? CAGradientLayer else {
assertionFailure()
return
}
gradientLayer.colors = [
UIColor(red: 37/255, green: 176/255, blue: 176/255, alpha: 1).cgColor,
UIColor(red: 72/255, green: 72/255, blue: 171/255, alpha: 1).cgColor,
]
}
override func layoutSubviews() {
super.layoutSubviews()
guard let gradientLayer = layer as? CAGradientLayer else {
assertionFailure()
return
}
gradientLayer.startPoint = CGPoint(x: 0.2, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.8, y: 1)
}
}
+55
View File
@@ -0,0 +1,55 @@
//
// PillButton.swift
// Examples
//
// Created by Drew Olbrich on 12/24/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A button with rounded ends.
class PillButton: UIButton {
private let normalOutlineColor = UIColor(white: 1, alpha: 0.4)
private let disabledOutlineColor = UIColor(white: 1, alpha: 0.25)
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
commonInit()
}
private func commonInit() {
adjustsImageWhenHighlighted = false
adjustsImageWhenDisabled = false
titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .medium)
setTitleColor(.white, for: .normal)
setTitleColor(disabledOutlineColor, for: .disabled)
}
override func layoutSubviews() {
super.layoutSubviews()
if backgroundImage(for: .normal)?.size.height != bounds.height {
let normalBackgroundImage = roundedCornersImage(fillColor: nil, outlineColor: normalOutlineColor, cornerRadius: bounds.height/2)
setBackgroundImage(normalBackgroundImage, for: .normal)
let disabledBackgroundImage = roundedCornersImage(fillColor: nil, outlineColor: disabledOutlineColor, cornerRadius: bounds.height/2)
setBackgroundImage(disabledBackgroundImage, for: .disabled)
}
}
override var intrinsicContentSize: CGSize {
return CGSize(width: 280, height: 44)
}
}
+84
View File
@@ -0,0 +1,84 @@
//
// PillTextField.swift
// Examples
//
// Created by Drew Olbrich on 12/24/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A text field with rounded ends.
class PillTextField: UITextField {
private let fillColor = UIColor(white: 1, alpha: 0.1)
private let outlineColor = UIColor(white: 1, alpha: 0.15)
private let placeholderColor = UIColor(white: 1, alpha: 0.4)
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
commonInit()
}
private func commonInit() {
borderStyle = .none
font = UIFont.systemFont(ofSize: 17, weight: .medium)
textColor = .white
// The insertion point's color and the color of selected text.
tintColor = .white
updateAttributedPlaceholder()
}
override func layoutSubviews() {
super.layoutSubviews()
if background?.size.height != bounds.height {
background = roundedCornersImage(fillColor: fillColor, outlineColor: outlineColor, cornerRadius: bounds.height/2)
}
}
override var placeholder: String? {
didSet {
updateAttributedPlaceholder()
}
}
private func updateAttributedPlaceholder() {
let placeholderAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: placeholderColor,
.font: UIFont.systemFont(ofSize: 17, weight: .medium),
]
if let placeholder = placeholder {
attributedPlaceholder = NSAttributedString(string: placeholder, attributes: placeholderAttributes)
} else {
attributedPlaceholder = nil
}
}
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
return textRect(forBounds: bounds)
}
override func editingRect(forBounds bounds: CGRect) -> CGRect {
return textRect(forBounds: bounds)
}
override func textRect(forBounds bounds: CGRect) -> CGRect {
return bounds.insetBy(dx: round(bounds.height*0.45), dy: 0)
}
override var intrinsicContentSize: CGSize {
return CGSize(width: 280, height: 44)
}
}
+51
View File
@@ -0,0 +1,51 @@
//
// RoundedCornersImage.swift
// Examples
//
// Created by Drew Olbrich on 12/24/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// Returns an image containing a rounded corners rectangle with a filled region and
/// outline.
///
/// - Parameters:
/// - fillColor: The color to fill the rectangle with.
/// - outlineColor: The color of the outline.
/// - cornerRadius: The radius of the rounded corners.
/// - outlineWidth: The stroke width of the outline.
/// - Returns: A rounded corners rectangle image.
func roundedCornersImage(fillColor: UIColor?, outlineColor: UIColor?, cornerRadius: CGFloat, outlineWidth: CGFloat = 1) -> UIImage? {
let size = CGSize(width: cornerRadius*2, height: cornerRadius*2)
let rect = CGRect(origin: .zero, size: size)
UIGraphicsBeginImageContext(size)
guard let context = UIGraphicsGetCurrentContext() else {
return nil
}
context.clear(rect)
if let fillColor = fillColor {
context.setFillColor(fillColor.cgColor)
context.fillEllipse(in: rect)
}
if let outlineColor = outlineColor {
context.setStrokeColor(outlineColor.cgColor)
context.strokeEllipse(in: rect.insetBy(dx: outlineWidth/2, dy: outlineWidth/2))
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
let capInsets = UIEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius)
return image?.resizableImage(withCapInsets: capInsets)
}
+128
View File
@@ -0,0 +1,128 @@
//
// SignUpController.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/9/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// Helper class that encapsulates code common to all
/// `ScrollingContentViewController` example applications.
class SignUpController: NSObject {
private weak var nameTextField: UITextField?
private weak var emailTextField: UITextField?
private weak var passwordTextField: UITextField?
private weak var signUpButton: UIButton?
private weak var delegate: SignUpControllerDelegate?
init(logoImageView: UIImageView, nameTextField: UITextField, emailTextField: UITextField, passwordTextField: UITextField, signUpButton: UIButton, signInButton: UIButton, delegate: SignUpControllerDelegate) {
super.init()
self.nameTextField = nameTextField
self.emailTextField = emailTextField
self.passwordTextField = passwordTextField
self.signUpButton = signUpButton
self.delegate = delegate
logoImageView.tintColor = .white
nameTextField.delegate = self
emailTextField.delegate = self
passwordTextField.delegate = self
nameTextField.addTarget(self, action: #selector(updateSignUpButtonIsEnabledState), for: .editingChanged)
emailTextField.addTarget(self, action: #selector(updateSignUpButtonIsEnabledState), for: .editingChanged)
passwordTextField.addTarget(self, action: #selector(updateSignUpButtonIsEnabledState), for: .editingChanged)
signUpButton.isEnabled = false
configureSignInButton(signInButton)
}
@objc func updateSignUpButtonIsEnabledState() {
guard let nameTextField = nameTextField,
let emailTextField = emailTextField,
let passwordTextField = passwordTextField else {
return
}
// In a real app, this test should be more sophisticated and perform full
// validation on each field separately according to its type.
let isEnabled = !textFieldIsEmpty(nameTextField) && !textFieldIsEmpty(emailTextField) && !textFieldIsEmpty(passwordTextField)
signUpButton?.isEnabled = isEnabled
}
/// If `true`, the text field contains the empty string, after trimming leading and
/// trailing whitespace.
private func textFieldIsEmpty(_ textField: UITextField) -> Bool {
guard let text = trimmedText(of: textField) else {
return true
}
return text.isEmpty
}
/// Strips leading and trailing whitespace.
private func trimmedText(of textField: UITextField) -> String? {
return textField.text?.trimmingCharacters(in: CharacterSet.whitespaces)
}
private func configureSignInButton(_ signInButton: UIButton) {
let signInButtonTitleColor: UIColor = .white
let signInButtonTitleFontSize: CGFloat = 15
let signInButtonTitle = NSMutableAttributedString()
let signInButtonTitleRegularFontAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: signInButtonTitleColor,
.font: UIFont.systemFont(ofSize: signInButtonTitleFontSize, weight: .regular),
]
let signInButtonTitleMediumFontAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: signInButtonTitleColor,
.font: UIFont.systemFont(ofSize: signInButtonTitleFontSize, weight: .medium),
]
signInButtonTitle.append(NSAttributedString(string: "Already have an account? ", attributes: signInButtonTitleRegularFontAttributes))
signInButtonTitle.append(NSAttributedString(string: "Sign In", attributes: signInButtonTitleMediumFontAttributes))
signInButton.setAttributedTitle(signInButtonTitle, for: .normal)
}
}
extension SignUpController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
switch textField {
case nameTextField:
emailTextField?.becomeFirstResponder()
scrollFirstResponderToVisible()
case emailTextField:
passwordTextField?.becomeFirstResponder()
scrollFirstResponderToVisible()
case passwordTextField:
passwordTextField?.resignFirstResponder()
default:
assertionFailure("Unrecognized text field")
}
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
textField.text = trimmedText(of: textField)
}
private func scrollFirstResponderToVisible() {
delegate?.signUpControllerScrollFirstResponderToVisible(self)
}
}
@@ -0,0 +1,20 @@
//
// SignUpControllerDelegate.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/9/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// Delegate for SignUpController.
protocol SignUpControllerDelegate: class {
/// Tells the delegate to scroll the scroll view so that the first responder becomes
/// visible.
func signUpControllerScrollFirstResponderToVisible(_ signUpController: SignUpController)
}
@@ -0,0 +1,22 @@
//
// AppDelegate.swift
// StoryboardExample
//
// Created by Drew Olbrich on 1/7/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
return true
}
}
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,69 @@
//
// SignUpViewController.swift
// StoryboardExample
//
// Created by Drew Olbrich on 12/23/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
import ScrollingContentViewController
/// A class that demonstrates configuring `ScrollingContentViewController` in
/// Interface Builder using storyboards.
class SignUpViewController: ScrollingContentViewController {
/// Helper object that encapsulates code common to all
/// `ScrollingContentViewController` example applications.
private var signUpController: SignUpController?
@IBOutlet weak var logoImageView: UIImageView!
@IBOutlet weak var nameTextField: PillTextField!
@IBOutlet weak var emailTextField: PillTextField!
@IBOutlet weak var passwordTextField: PillTextField!
@IBOutlet weak var signUpButton: PillButton!
@IBOutlet weak var signInButton: UIButton!
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func loadView() {
// Load all controls and connect all outlets defined by Interface Builder.
super.loadView()
// Replace the root view with a gradient.
view = GradientBackgroundView()
}
override func viewDidLoad() {
// Insert the scroll view as a superview of the content view.
super.viewDidLoad()
// Set the content view's background color to transparent so the gradient
// background root view can be seen behind it.
contentView.backgroundColor = nil
// Allow the content view to shrink vertically when the keyboard is presented.
shouldResizeContentViewForKeyboard = true
// Allow the user to dismiss the keyboard by dragging from the scroll view to the
// bottom of the screen.
scrollView.keyboardDismissMode = .interactive
signUpController = SignUpController(logoImageView: logoImageView, nameTextField: nameTextField, emailTextField: emailTextField, passwordTextField: passwordTextField, signUpButton: signUpButton, signInButton: signInButton, delegate: self)
}
}
extension SignUpViewController: SignUpControllerDelegate {
func signUpControllerScrollFirstResponderToVisible(_ signUpController: SignUpController) {
scrollView.scrollFirstResponderToVisible(animated: true)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright 2019 Oath Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+423
View File
@@ -0,0 +1,423 @@
# ScrollingContentViewController
[![Travis](https://img.shields.io/travis/drewolbrich/ScrollingContentViewController.svg)](https://travis-ci.org/drewolbrich/ScrollingContentViewController)
[![Platform](https://img.shields.io/badge/platform-iOS-lightgray.svg)](http://developer.apple.com/ios)
[![Swift 4.2](https://img.shields.io/badge/swift-4.2-red.svg?style=flat)](https://developer.apple.com/swift)
[![License](https://img.shields.io/github/license/drewolbrich/ScrollingContentViewController.svg)](LICENSE)
[![Twitter](https://img.shields.io/badge/twitter-@drewolbrich-blue.svg)](http://twitter.com/drewolbrich)
* [Overview](#overview)
* [Background](#background)
* [Installation](#installation)
* [Usage](#usage)
* [Caveats](#caveats)
* [Usage Without Subclassing](#usage-without-subclassing)
* [Examples](#examples)
* [Properties](#properties)
* [Scroll View Properties and Methods](#scroll-view-properties-and-methods)
* [How It Works](#how-it-works)
* [Special Cases Handled](#special-cases-handled)
* [License](#license)
## Overview
ScrollingContentViewController makes it easy to create a view controller with a scrolling content view, or to convert an existing static view controller into one that scrolls. Most importantly, it takes care of several tricky undocumented edge cases involving the keyboard, navigation controllers, and device rotations.
## Background
A common UIKit Auto Layout task involves creating a view controller with a fixed layout that is too large to fit older, smaller devices, or devices in landscape orientation, or the area of the screen that remains visible when the keyboard is presented.
For example, consider this sign up screen, which fits iPhone XS, but not iPhone SE with a keyboard:
<img src="https://github.com/drewolbrich/ScrollingContentViewController/raw/master/Images/Overview-Comparison.png" width="888px">
This case can be handled by nesting the view inside a scroll view. You can do this manually in Interface Builder, as described by Apple's [Working with Scroll Views](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/WorkingwithScrollViews.html) documentation, but many steps are required. If your view contains text fields, you'll have to write code to compensate for the keyboard when it's presented, as in [Managing the Keyboard](https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html#//apple_ref/doc/uid/TP40009542-CH5-SW3). However, handling the keyboard robustly is [surprisingly complicated](#keyboard-resize-filtering), especially if your app presents a sequence of screens with keyboards in the context of a navigation controller, or when device orientation support is required.
To simplify this task, ScrollingContentViewController inserts the scroll view into the view hierarchy for you, along with all necessary Auto Layout constraints. When used in a storyboard, ScrollingContentViewController exposes a [`contentView`](#contentView) outlet that you connect to the view that you'd like make scrollable. Everything else is taken care of automatically.
ScrollingContentViewController can be configured using storyboards or entirely in code. The easiest way to use it is by subclassing the `ScrollingContentViewController` class instead of [`UIViewController`](https://developer.apple.com/documentation/uikit/uiviewcontroller). However, when this is not an option, a helper class called `ScrollingContentViewManager` can be composed with your existing view controller class instead.
An explanation of [how ScrollingContentViewController works internally](#how-it-works) is provided below.
## Installation
To install ScrollingContentViewController using CocoaPods, add this line to your Podfile:
```ruby
pod 'ScrollingContentViewController'
```
## Usage
Subclasses of `ScrollingContentViewController` may be configured using [storyboards](#storyboards) or in [code](#code).
This library can also be used without subclassing, by composing the helper class `ScrollingContentViewManager` instead. See [Usage Without Subclassing](#usage-without-subclassing).
### Storyboards
To configure `ScrollingContentViewController` in a storyboard:
1. Create a subclass of `ScrollingContentViewController` and add a new view controller with that class in Interface Builder. Or, if you have an existing view controller that subclasses [`UIViewController`](https://developer.apple.com/documentation/uikit/uiviewcontroller), modify your view controller to subclass `ScrollingContentViewController` instead.
```swift
import ScrollingContentViewController
class MyViewController: ScrollingContentViewController {
// ...
}
```
2. In Interface Builder's outline view, control-click your view controller and connect its [`contentView`](#contentView) outlet to your view controller's root view.
<img src="https://github.com/drewolbrich/ScrollingContentViewController/raw/master/Images/Usage-Storyboards.png" width="471px">
3. If your view controller defines a [`viewDidLoad`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621495-viewdidload) method, call `super.viewDidLoad` if you aren't already doing so.
```swift
override func viewDidLoad() {
super.viewDidLoad()
// ...
}
```
4. At runtime, the `ScrollingContentViewController` property [`contentView`](#contentView) will now reference the superview of the controls that you laid out in Interface Builder. This superview will no longer be referenced by the [`view`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621460-view) property, which will instead reference an empty root view behind the scrolling content view. If necessary, revise your code to reflect this change.
Your content view will now scroll, provided that you ensure that the content view's Auto Layout constraints [sufficiently define its size](#auto-layout-considerations).
### Code
To integrate `ScrollingContentViewController` programmatically:
1. Subclass `ScrollingContentViewController` instead of [`UIViewController`](https://developer.apple.com/documentation/uikit/uiviewcontroller).
```swift
import ScrollingContentViewController
class MyViewController: ScrollingContentViewController {
// ...
}
```
2. In your view controller's [`viewDidLoad`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621495-viewdidload) method, assign a new view to the [`contentView`](#contentView) property. Add all of your controls to this view instead of referencing the [`view`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621460-view) property so they can scroll freely. The view controller's root view referenced by its [`view`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621460-view) property now acts as a background view behind the scrolling content view.
```swift
override func viewDidLoad() {
super.viewDidLoad()
contentView = UIView()
// Add all controls to contentView instead of view.
}
```
## Caveats
### Auto Layout Considerations
For ScrollingContentViewController to determine the height of the scroll view's content, the content view must contain an unbroken chain of constraints and views stretching from the content views top edge to its bottom edge. This is also true for the content view's width. This is consistent with the approach described by Apple's [Working with Scroll Views](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/WorkingwithScrollViews.html) documentation.
If you don't define sufficient Auto Layout constraints, ScrollingContentViewController won't be able to determine the size of your content view, and it will not scroll as expected.
If you'd like your content view to stretch to take advantage of the full visible area of the scroll view, relax your constraints to allow for this. For example, in Interface Builder, change the Relation property of one of your height constraints to Greater Than or Equal.
To determine the size of the scroll view's content size, ScrollingContentViewController creates width and height constraints with a relation greater than or equal to the width and height of the scroll view's safe area. The priority of these constraints is 500. Consequently, if you create an unbroken chain of constraints with priority [`defaultHigh`](https://developer.apple.com/documentation/uikit/uilayoutpriority/1622249-defaulthigh) (750) or [`required`](https://developer.apple.com/documentation/uikit/uilayoutpriority/1622241-required) (1000), they will take precedence over ScrollingContentViewController's internal minimum width and height constraints, and your content view will not stretch to fill the scroll view's safe area.
If the size of your view controller is intentionally highly constrained (e.g. consisting exclusively of constraints with `required` priority and lacking [`greaterThanOrEqual`](https://developer.apple.com/documentation/uikit/nslayoutconstraint/relation/greaterthanorequal) relation constraints), you may see Auto Layout constraint errors in Interface Builder if the constraints don't match the simulated size of the view, for example, when you switch between simulated device sizes. The easiest way to resolve this issue is to reduce the priority of one of your constraints. The value 240 is a good choice because it is lower than the default content hugging priority (250) and consequently, it will help avoid the undesirable behavior where text fields and labels without height constraints stretch vertically.
<img src="https://github.com/drewolbrich/ScrollingContentViewController/raw/master/Images/Usage-Auto-Layout-Considerations.png" width="663px">
### Intrinsic Content Size
If you'd prefer not to use Auto Layout, the content view's size may be specified using [`intrinsicContentSize`](https://developer.apple.com/documentation/uikit/uiview/1622600-intrinsiccontentsize) instead of constraints.
The default `UIView` content hugging priority is [`defaultLow`](https://developer.apple.com/documentation/uikit/uilayoutpriority/1622250-defaultlow), and consequently, the content view's intrinisic content size will normally be overridden by the minimum size constraints that ScrollingContentViewController assigns. If you'd like `intrinsicContentSize` to take precedence over these constraints, set the content view's content hugging priority to [`required`](https://developer.apple.com/documentation/uikit/uilayoutpriority/1622241-required).
### Changing the Background Color
The content view is positioned within the scroll view's safe area. Consequently, the content view's background color won't extend underneath the status bar, home indicator, navigation bar, or toolbar.
<img src="https://github.com/drewolbrich/ScrollingContentViewController/raw/master/Images/Caveats-Background-Color-Content-View.png" width="233px">
To specify a background color that extends to the edges of the screen:
1. Set the background color of the view controller's root view to the desired color. This view will be visible behind the scroll view, which is transparent.
2. Set the content view's background color to `nil` so it is also transparent.
For example:
```swift
view.backgroundColor = UIColor(red: 1, green: 0.949, blue: 0.788, alpha: 1)
contentView.backgroundColor = nil
```
### Resizing the Content View
If you make changes to your content view that modify its size, you must call the scroll view's [`setNeedsLayout`](https://developer.apple.com/documentation/uikit/uiview/1622601-setneedslayout) method, or otherwise the scroll view's content size won't be updated to reflect the size change, and your view may not scroll correctly.
For example, after updating the view's [`NSLayoutConstraint.constant`](https://developer.apple.com/documentation/uikit/nslayoutconstraint/1526928-constant) properties, you may animate the changes like this:
```swift
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0,
options: [], animations: {
self.scrollView.setNeedsLayout()
self.scrollView.layoutIfNeeded()
}, completion: nil)
```
### Oversized View Controllers
In Interface Builder, it's possible to design a view controller that is intentionally larger than the height of the screen. To do this, change the view controller's simulated size to Freeform and adjust its height. When used with ScrollingContentViewController, the view controller's oversized content view will scroll freely, assuming its constraints require it to be larger than the screen.
<img src="https://github.com/drewolbrich/ScrollingContentViewController/raw/master/Images/Usage-Oversized-View-Controllers.png" width="610px">
## Usage Without Subclassing
When subclassing `ScrollingContentViewController` is not an option, the helper class `ScrollingContentViewManager` can be composed with your view controller instead:
```swift
import ScrollingContentViewController
class MyViewController: UIViewController {
lazy var scrollingContentViewManager = ScrollingContentViewManager(hostViewController: self)
@IBOutlet weak var contentView: UIView!
override func loadView() {
// Load all controls and connect all outlets defined by Interface Builder.
super.loadView()
scrollingContentViewManager.loadView(forContentView: contentView)
}
override func viewDidLoad() {
super.viewDidLoad()
// When ScrollingContentViewManager.contentView is first assigned, this has the
// side effect of adding a scroll view to the content view's superview, and
// adding the content view to the scroll view.
scrollingContentViewManager.contentView = contentView
// Set the content view's background color to transparent so the root view is
// visible behind it.
contentView.backgroundColor = nil
}
// Note: This is only required in apps that support device orientation changes.
override func viewWillTransition(to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
scrollingContentViewManager.viewWillTransition(to: size, with: coordinator)
}
/// Note: This is only required in apps with navigation controllers that are used to
/// push sequences of view controllers with text fields that become the first
/// responder in `viewWillAppear`.
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
scrollingContentViewManager.viewSafeAreaInsetsDidChange()
}
}
```
The `ScrollingContentViewManager` class supports all of the same [properties](#properties) and [methods](#methods) as `ScrollingContentViewController`.
`ScrollingContentViewManager` can also be used to create a scrolling view controller programatically:
```swift
import ScrollingContentViewController
class MyViewController: UIViewController {
lazy var scrollingContentViewManager = ScrollingContentViewManager(hostViewController: self)
var contentView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
// Populate your content view here.
// ...
// When ScrollingContentViewManager.contentView is first assigned, this has the
// side effect of adding a scroll view to the view controller's root view, and
// adding the content view to the scroll view.
scrollingContentViewManager.contentView = contentView
}
// Note: This is only required in apps that support device orientation changes.
override func viewWillTransition(to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
scrollingContentViewManager.viewWillTransition(to: size, with: coordinator)
}
/// Note: This is only required in apps with navigation controllers that are used to
/// push sequences of view controllers with text fields that become the first
/// responder in `viewWillAppear`.
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
scrollingContentViewManager.viewSafeAreaInsetsDidChange()
}
}
```
## Examples
* [StoryboardExample](Examples/StoryboardExample) - Example configuring `ScrollingContentViewController` in a storyboard.
* [CodeExample](Examples/CodeExample) - Example using code only.
* [ManagerExample](Examples/ManagerExample) - Example using `ScrollingContentViewManager` and class composition instead of subclassing `ScrollingContentViewController`.
* [SequenceExample](Examples/SequenceExample) - Example of a sequence of pushed scrolling view controllers with keyboards in the context of a navigation controller.
* [ReassignExample](Examples/ReassignExample) - Example of dynamically reassigning `contentView`.
## Properties
The `ScrollingContentViewController` and `ScrollingContentViewManager` classes share the following properties:
### contentView
The scrolling content view parented to the scroll view.
When this property is first assigned, the view that it references is parented to [`scrollView`](#scrollView), which is then added to the view controller's view hierarchy.
If the content view already has a superview, the scroll view replaces it in the view hierarchy and all of the superview's constraints that reference the content view are retargeted to the content view. The content view's width and height constraints and autoresizing mask are transferred to the scroll view.
If the content view has no superview, the scroll view is parented to the view controller's root view and its frame and autoresizing mask are defined to track the root view's bounds.
If the [`contentView`](#contentView) property is later reassigned, the new content view replaces the old one as the subview of the scroll view, and the scroll view is left otherwise unmodified.
### scrollView
The scroll view to which [`contentView`](#contentView) is parented.
You may safely modify any of the scroll view's properties. For example, setting [`keyboardDismissMode`](https://developer.apple.com/documentation/uikit/uiscrollview/1619437-keyboarddismissmode) to [`interactive`](https://developer.apple.com/documentation/uikit/uiscrollview/keyboarddismissmode/interactive) or [`onDrag`](https://developer.apple.com/documentation/uikit/uiscrollview/keyboarddismissmode/ondrag) will allow the user to dismiss the keyboard by dragging the scroll view.
The scroll view is implemented as a subclass of [`UIScrollView`](https://developer.apple.com/documentation/uikit/uiscrollview) that provides [additional properties and methods](#scroll-view-properties-and-methods) which you may use to modify its behavior.
### shouldResizeContentViewForKeyboard
A Boolean value that determines whether or not the content view is resized when the keyboard is presented.
* `true` - When the keyboard is presented, the content view shrinks to fit the portion of the scroll view not overlapped by the keyboard, to the extent that this is permitted by the content view's Auto Layout constraints. With an appropriate use of constraints, this may allow for more effective use of the reduced screen real estate.
* `false` - When the keyboard is presented, the content view's size remains unchanged. This is the default value.
### shouldAdjustAdditionalSafeAreaInsetsForKeyboard
A Boolean value that determines whether or not the view controller's [`additionalSafeAreaInsets`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2902284-additionalsafeareainsets) property is adjusted when the keyboard is presented.
* `true` - When the keyboard is presented, the view controller's [`additionalSafeAreaInsets`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2902284-additionalsafeareainsets) property is adjusted to compensate for the portion of the scroll view that is overlapped by the keyboard, ensuring that all of the content view's content is accessible via scrolling. This is the default value.
* `false` - When the keyboard is presented, the view controller's [`additionalSafeAreaInsets`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2902284-additionalsafeareainsets) property remains unchanged. Assign this value if you'd prefer to implement your own keyboard presentation compensation behavior.
## Scroll View Properties and Methods
The scroll view referenced by the [`scrollView`](#scrollView) property of `ScrollingContentViewController` and `ScrollingContentViewManager` provides the following additional properties and methods, beyond those normally provided by [`UIScrollView`](https://developer.apple.com/documentation/uikit/uiscrollview):
### visibilityScrollMargin
A floating point value representing a vertical margin applied to the first responder view frame when the scroll view is scrolled to make the first responder visible. The default value is 0, which matches the UIKit default behavior.
### scrollRectToVisible(animated:margin:)
Scrolls the scroll view to make the rect visible.
The optional `margin` parameter specifies an extra margin around the rect which is also made visible. If `margin` is unspecified or `nil`, the value of [`visibilityScrollMargin`](#visibilityScrollMargin) will be used instead.
### scrollViewToVisible(animated:margin:)
Scrolls the scroll view to make the specified view visible.
The optional `margin` parameter specifies an extra margin around the view which is also made visible. If `margin` is unspecified or `nil`, the value of [`visibilityScrollMargin`](#visibilityScrollMargin) will be used instead.
### scrollFirstResponderToVisible(animated:margin:)
Scrolls the scroll view to make the first responder visible. If no first responder is defined, this method has no effect.
The optional `margin` parameter specifies an extra margin around the first responder which is also made visible. If `margin` is unspecified or `nil`, the value of [`visibilityScrollMargin`](#visibilityScrollMargin) will be used instead.
## How It Works
### View Hierarchy
ScrollingContentViewController inserts a scroll view between the content view and its superview, using Auto Layout to constrain the scroll view's content layout guide to the size of the content view. The content view's size is also constrained to be greater than or equal to the size of the scroll view's safe area, so it can utilize the full area of the screen assigned to the scroll view.
<img src="https://github.com/drewolbrich/ScrollingContentViewController/raw/master/Images/How-It-Works-View-Hierarchy.png" width="496px">
When the content view is first assigned, if it has a superview, the scroll view replaces it in the view hierarchy and all of the superview's constraints that reference the content view are retargeted to the content view. The content view's width and height constraints and autoresizing mask are transferred to the scroll view.
If the content view has no superview, the scroll view is parented to the view controller's root view and its frame and autoresizing mask are defined to track the root view's bounds.
If the ScrollingContentViewController's `contentView` property references its root view, a new `UIView` is allocated and replaces it as the root view so that the scroll view will have an appropriate view to be parented view.
The content view's superview does not necessarily have to be the view controller's root view, and does not have to match the root view's size.
Refer to Apple's [Working with Scroll Views](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/WorkingwithScrollViews.html) documentation for a detailed description of how scroll views are used with Auto Layout.
### Additional Safe Area Insets
When the keyboard is presented, ScrollingContentViewController modifies the container view controller's [`additionalSafeAreaInsets`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2902284-additionalsafeareainsets) property to compensate for the area of the keyboard that overlaps the scroll view, as recommended in Apple's [Managing the Keyboard](https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html#//apple_ref/doc/uid/TP40009542-CH5-SW3) documentation.
Although ScrollingContentViewController modifies [`additionalSafeAreaInsets`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2902284-additionalsafeareainsets) when the keyboard is presented, it restores it to its original value when the keyboard is dismissed. This allows [`additionalSafeAreaInsets`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2902284-additionalsafeareainsets) to be used for other purposes, such as custom tool palettes.
During development, an alternate approach suggested by Apple, modifying the scroll view's content size, was also tried. This requires adjusting the scroll view's [`scrollIndicatorInsets`](https://developer.apple.com/documentation/uikit/uiscrollview/1619427-scrollindicatorinsets) property to compensate for the content size change. On iPhone XS in landscape orientation, doing so has the unfortunate side effect of awkwardly shifting the scroll indicator away from the edge of the screen.
### Keyboard Resize Filtering
When a text field becomes the first responder, UIKit presents the keyboard. If the user taps another text field, changing the first responder, UIKit may adjust the keyboard's height if an input accessory view is specified. These changes may generate a sequence of [`keyboardWillShow`](https://developer.apple.com/documentation/uikit/uiresponder/1621576-keyboardwillshownotification) notifications, each with different keyboard heights.
As an extreme example, if the user populates a text field by tapping on an AutoFill input accessory view, and this action causes a password text field to automatically become the first responder, one [`keyboardWillHide`](https://developer.apple.com/documentation/uikit/uikeyboardwillhidenotification) notification and two [`keyboardWillShow`](https://developer.apple.com/documentation/uikit/uiresponder/1621576-keyboardwillshownotification) notifications will be posted within a span of 0.1 seconds.
If ScrollingContentViewController were to respond to each of these notifications individually, this would cause awkward discontinuities in the scroll view animation that accompanies changes to the keyboard's height.
To work around this issue, ScrollingContentViewController filters out sequences of notifications that occur within a small time window, acting only on the final assigned keyboard frame in the sequence. This appears to be consistent with the way Apple's iOS apps are implemented. As of iOS 12, Apple's apps respond to keyboard size changes only after a short delay, and do not animate their views in concert with the keyboard's animation.
During a device orientation transition, a [`keyboardWillHide`](https://developer.apple.com/documentation/uikit/uikeyboardwillhidenotification) notification is posted before the animation starts, followed by [`keyboardWillShow`](https://developer.apple.com/documentation/uikit/uiresponder/1621576-keyboardwillshownotification) after it ends, even though the keyboard remains visible during the transition. Because the duration of the animation exceeds the filtering time window, it is therefore necessary to temporarily suspend filtering during the transition. Otherwise, the content view would resize unnecessarily.
Finally, ScrollingContentViewController correctly handles the case where changes to the size or layout of the scroll view's content may occur in response to keyboard presentation or device orientation changes (in particular when [`shouldResizeContentViewForKeyboard`](#shouldResizeContentViewForKeyboard) is `true`), invaliding the coordinate space of the rectangle passed to `scrollRectToVisible` (most importantly, in the case when that method is called automatically by iOS after keyboard changes) which would otherwise result in the scroll view scrolling by an inappropriate amount or leaving the scroll view with a content offset that is outside of the legal scrolling range.
Refer to Apple's [Managing the Keyboard](https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html#//apple_ref/doc/uid/TP40009542-CH5-SW3) documentation for more information about responding to changes in keyboard visibility.
## Special Cases Handled
In addition to [keyboard resize filtering](#keyboard-resize-filtering), above, ScrollingContentViewController addresses a few other edge cases:
### Navigation Controllers
ScrollingContentViewController correctly handles sequences of pushed view controllers in the context of a navigation controller, in particular in the case when each view controller calls a text field's [`becomeFirstResponder`](https://developer.apple.com/documentation/uikit/uiresponder/1621113-becomefirstresponder) method in [`viewWillAppear`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621510-viewwillappear), such that the keyboard remains visible across view controller transitions.
### Device Orientation Changes
When device orientation changes occur, ScrollingContentViewController improves upon the default scroll view behavior by pinning the upper left corner of the scroll view in place, while at the same time preventing out of range content offsets. This matches the behavior of many of Apple's iOS apps.
### keyboardDismissMode Enhancement
ScrollingContentViewController automatically enables [`UIScrollView.alwaysBounceVertical`](https://developer.apple.com/documentation/uikit/uiscrollview/1619383-alwaysbouncevertical) while the keyboard is presented if [`UIScrollView.keyboardDismissMode`](https://developer.apple.com/documentation/uikit/uiscrollview/1619437-keyboarddismissmode) is set to anything other than [`none`](https://developer.apple.com/documentation/uikit/uiscrollview/keyboarddismissmode/none), so the keyboard can be dismissed even if the view is too short to normally allow scrolling.
### Arbitrary Scroll View Sizes
ScrollingContentViewController correctly handles the case when the scroll view doesn't cover the full extent of the screen, in which case it may only partially intersect the keyboard.
### Text Field Animation Artifact Fix
As of iOS 12, if the user taps a sequence of custom text fields, UIKit may awkwardly animate the text field's text. ScrollingContentViewController suppresses this animation.
## License
This project is licensed under the terms of the MIT open source license. Please refer to the file [LICENSE](LICENSE) for the full terms.
+26
View File
@@ -0,0 +1,26 @@
Pod::Spec.new do |s|
s.name = 'ScrollingContentViewController'
s.version = '1.0.0'
s.summary = 'A Swift class that simplifies making a view controller\'s view scrollable'
s.description = <<-DESC
ScrollingContentViewController makes it easy to create a view controller with a
scrolling content view, or to convert an existing static view controller into
one that scrolls. Most importantly, it takes care of several tricky undocumented
edge cases involving the keyboard, navigation controllers, and device rotations.
DESC
s.homepage = 'https://github.com/drewolbrich/ScrollingContentViewController'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = { 'drewolbrich' => 'drew@retroactivefiasco.com' }
s.source = { :git => 'https://github.com/drewolbrich/ScrollingContentViewController.git', :tag => s.version.to_s }
s.social_media_url = 'https://twitter.com/drewolbrich'
s.ios.deployment_target = '11.0'
s.source_files = 'Source/**/*.swift'
s.frameworks = 'UIKit'
s.swift_version = '4.2'
end
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:ScrollingContentViewController.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>FILEHEADER</key>
<string>
// ___FILENAME___
// ___TARGETNAME___
//
// Created by ___FULLUSERNAME___ on ___DATE___.
// Copyright 2019 ___ORGANIZATIONNAME___
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//</string>
</dict>
</plist>
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1010"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04D921E4514B00D94DA5"
BuildableName = "CodeExample.app"
BlueprintName = "CodeExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04D921E4514B00D94DA5"
BuildableName = "CodeExample.app"
BlueprintName = "CodeExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04D921E4514B00D94DA5"
BuildableName = "CodeExample.app"
BlueprintName = "CodeExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04D921E4514B00D94DA5"
BuildableName = "CodeExample.app"
BlueprintName = "CodeExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1010"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A6273DE21E79757008EA567"
BuildableName = "ManagerExample.app"
BlueprintName = "ManagerExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A6273DE21E79757008EA567"
BuildableName = "ManagerExample.app"
BlueprintName = "ManagerExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A6273DE21E79757008EA567"
BuildableName = "ManagerExample.app"
BlueprintName = "ManagerExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A6273DE21E79757008EA567"
BuildableName = "ManagerExample.app"
BlueprintName = "ManagerExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1010"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A5702CD21E2CBB600E4CC55"
BuildableName = "ScrollingContentViewController.framework"
BlueprintName = "ScrollingContentViewController"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
codeCoverageEnabled = "YES"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A5702D621E2CBB600E4CC55"
BuildableName = "ScrollingContentViewControllerTests.xctest"
BlueprintName = "ScrollingContentViewControllerTests"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A5702CD21E2CBB600E4CC55"
BuildableName = "ScrollingContentViewController.framework"
BlueprintName = "ScrollingContentViewController"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A5702CD21E2CBB600E4CC55"
BuildableName = "ScrollingContentViewController.framework"
BlueprintName = "ScrollingContentViewController"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3A5702CD21E2CBB600E4CC55"
BuildableName = "ScrollingContentViewController.framework"
BlueprintName = "ScrollingContentViewController"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1010"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AE460E721EA952000B3E547"
BuildableName = "SequenceExample.app"
BlueprintName = "SequenceExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AE460E721EA952000B3E547"
BuildableName = "SequenceExample.app"
BlueprintName = "SequenceExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AE460E721EA952000B3E547"
BuildableName = "SequenceExample.app"
BlueprintName = "SequenceExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AE460E721EA952000B3E547"
BuildableName = "SequenceExample.app"
BlueprintName = "SequenceExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1010"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04A921E3A01900D94DA5"
BuildableName = "StoryboardExample.app"
BlueprintName = "StoryboardExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04A921E3A01900D94DA5"
BuildableName = "StoryboardExample.app"
BlueprintName = "StoryboardExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04A921E3A01900D94DA5"
BuildableName = "StoryboardExample.app"
BlueprintName = "StoryboardExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3AAC04A921E3A01900D94DA5"
BuildableName = "StoryboardExample.app"
BlueprintName = "StoryboardExample"
ReferencedContainer = "container:ScrollingContentViewController.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,77 @@
//
// AdditionalSafeAreaInsetsController.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 12/30/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// An object that adjusts the host view controller's
/// `additionalSafeAreaInsets.bottom` property to compensate for the portion of the
/// keyboard that overlaps the scroll view.
internal class AdditionalSafeAreaInsetsController {
private weak var delegate: AdditionalSafeAreaInsetsControlling?
/// The initial value of the `additionalSafeAreaInsets` property before the keyboard
/// was presented. The `additionalSafeAreaInsets` property is restored to this value
/// when the keyboard is dismissed.
private var initialAdditionalSafeAreaInsets: UIEdgeInsets?
init(delegate: AdditionalSafeAreaInsetsControlling) {
self.delegate = delegate
}
var bottomInset: CGFloat = 0 {
didSet {
guard let delegate = delegate,
let hostViewController = delegate.hostViewController,
let contentViewMinimumHeightConstraint = delegate.contentViewMinimumHeightConstraint else {
return
}
var adjustedBottomInset = bottomInset
if bottomInset != 0 && oldValue == 0 {
// The keyboard was presented.
let initialAdditionalSafeAreaInsets = hostViewController.additionalSafeAreaInsets
self.initialAdditionalSafeAreaInsets = initialAdditionalSafeAreaInsets
adjustedBottomInset = max(adjustedBottomInset, initialAdditionalSafeAreaInsets.bottom)
} else if bottomInset == 0 && oldValue != 0 {
// The keyboard was dismissed.
guard let initialAdditionalSafeAreaInsets = initialAdditionalSafeAreaInsets else {
assertionFailure()
return
}
adjustedBottomInset = initialAdditionalSafeAreaInsets.bottom
self.initialAdditionalSafeAreaInsets = nil
} else if bottomInset != oldValue {
// The keyboard changed size.
guard let initialAdditionalSafeAreaInset = initialAdditionalSafeAreaInsets else {
assertionFailure()
return
}
adjustedBottomInset = max(adjustedBottomInset, initialAdditionalSafeAreaInset.bottom)
} else {
// The size of the keyboard is unchanged.
return
}
if delegate.shouldResizeContentViewForKeyboard {
// Adjust the additional safe area insets, possibly reducing the size
// of the content view.
hostViewController.additionalSafeAreaInsets.bottom = adjustedBottomInset
} else {
// Adjust the additional safe area insets, but also increase the minimum height of
// the content view to compensate. The size of the content view will remain
// unchanged.
hostViewController.additionalSafeAreaInsets.bottom = adjustedBottomInset
contentViewMinimumHeightConstraint.constant = adjustedBottomInset
}
}
}
}
@@ -0,0 +1,26 @@
//
// AdditionalSafeAreaInsetsControlling.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// Delegate for `AdditionalSafeAreaInsetsController`.
internal protocol AdditionalSafeAreaInsetsControlling: class {
/// View controller whose `additionalSafeAreaInsets` property is manipulated.
var hostViewController: UIViewController? { get }
/// Manipulated content view minimum height constraint.
var contentViewMinimumHeightConstraint: NSLayoutConstraint? { get }
/// If `true`, the content view is allowed to shrink to compensate for the reduced
/// visible area of the screen when the keyboard is presented.
var shouldResizeContentViewForKeyboard: Bool { get }
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
//
// IsUnitTest.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/29/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import Foundation
/// `true` if the code is executing within the XCTest framework.
internal var isUnitTest: Bool {
return NSClassFromString("XCTest") != nil
}
+34
View File
@@ -0,0 +1,34 @@
//
// KeyboardFrameEvent.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/13/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import Foundation
/// An event encapsulating both the keyboard's frame and the duration of the
/// animation accompanying the change in the keyboard's frame, as reported by the
/// `keyboardWillShow` or `keyboardWillHide` notification upon which the event is
/// based.
internal struct KeyboardFrameEvent {
/// The frame of the keyboard in the window's coordinate space.
var keyboardFrame: CGRect
/// The duration of the keyboard's show or hide transition.
var duration: TimeInterval
/// Returns `true` if the keyboard frame event is the result of a
/// `UINavigationController` transition.
var isResultOfNavigationControllerTransition: Bool {
// As of iOS 12, the duration of a UINavigationController push or pop transition is
// 0.35 seconds. The transition duration for a keyboard presentation is 0.25
// seconds.
return duration > 0.3
}
}
+73
View File
@@ -0,0 +1,73 @@
//
// KeyboardNotificationManager.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A singleton that responds to `keyboardWillShow` and `keyboardWillHide`
/// notification events and forwards them to observers.
///
/// Through its `lastNotification` property, this class also exposes the last
/// received keyboard event, so that view controllers pushed by a navigation
/// controller can query the current frame of the keyboard, which would be
/// otherwise inaccessible to them, since it was determined before they were
/// created.
internal class KeyboardNotificationManager: NSObject {
static let shared = KeyboardNotificationManager()
private override init() {
super.init()
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(notifyObservers(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(notifyObservers(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
let notificationCenter = NotificationCenter.default
notificationCenter.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
notificationCenter.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}
private struct Observer {
weak var observing: KeyboardNotificationObserving?
}
private var observers: [Observer] = []
/// The last received keyboard notification.
private(set) var lastNotification: Notification?
/// Adds a keyboard notification observer.
///
/// - Parameter observing: The observer to notify when a keyboard notification is
/// received.
func addKeyboardNotificationObserver(_ observing: KeyboardNotificationObserving) {
observers.append(Observer(observing: observing))
}
/// Removes a keyboard notification observer.
///
/// - Parameter observing: The observer to remove from the list of observers to
/// notify when a keyboard notification is received.
func removeKeyboardNotificationObserver(_ observing: KeyboardNotificationObserving) {
observers.removeAll { $0.observing === observing }
}
/// Notifies all observers about a keyboard notification.
///
/// - Parameter notification: The notification to pass to the observers.
@objc private func notifyObservers(notification: Notification) {
lastNotification = notification
observers.forEach { $0.observing?.didReceiveKeyboardNotification(notification) }
}
}
@@ -0,0 +1,20 @@
//
// KeyboardNotificationObserving.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import Foundation
/// A protocol for objects that should be notified by `KeyboardNotificationManager`
/// when keyboard show or hide notifications are received.
internal protocol KeyboardNotificationObserving: class {
/// Tells the observer that a keyboard notification has been received.
func didReceiveKeyboardNotification(_ notification: Notification)
}
+317
View File
@@ -0,0 +1,317 @@
//
// KeyboardObserver.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 12/25/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// An object that responds to changes in the keyboard's visibility.
///
/// When the keyboard is presented or dismissed, or when the size of the keyboard
/// changes, `KeyboardObserver` compensates by calling
/// `KeyboardObservering.adjustViewForKeyboard(withBottomInset:)` method after a
/// short delay. See `ScrollViewFilter` for details.
internal class KeyboardObserver: NSObject {
// See https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html#//apple_ref/doc/uid/TP40009542-CH5-SW3
private weak var delegate: KeyboardObservering?
private weak var scrollViewFilter: ScrollViewFilter?
/// The duration of the animation of the change to the container view's bottom inset.
private let bottomInsetAnimationDuration: TimeInterval = 0.5
/// `true` if `viewSafeAreaInsetsDidChange` is executing. Used to avoid nested
/// calls to `updateForCurrentKeyboardVisibility`.
private var isAdjustingViewForKeyboardFrameEvent = false
init(scrollViewFilter: ScrollViewFilter, delegate: KeyboardObservering) {
super.init()
self.scrollViewFilter = scrollViewFilter
self.delegate = delegate
scrollViewFilter.keyboardDelegate = self
KeyboardNotificationManager.shared.addKeyboardNotificationObserver(self)
}
deinit {
KeyboardNotificationManager.shared.removeKeyboardNotificationObserver(self)
}
/// Returns `true` if filtering is suspended.
var isSuspended: Bool {
return scrollViewFilter?.isSuspended == true
}
/// Suspends filtering of changes to the keyboard's frame.
func suspend() {
scrollViewFilter?.suspend()
}
/// Resumes filtering of changes to the keyboard's frame.
func resume() {
scrollViewFilter?.resume()
}
/// Responds to changes in the view controller's safe area insets.
func viewSafeAreaInsetsDidChange() {
guard !isAdjustingViewForKeyboardFrameEvent else {
// Ignore safe area inset changes that result from self-induced changes to
// `additionalSafeAreaInsets` (as opposed to those generated by UIKit itself),
// which would result in nested calls to
// `scrollViewFilter(_:adjustViewForKeyboardFrameEvent:)`. These changes to
// `additionalSafeAreaInsets` are occurring in respond to changes that we are
// intentionally making to the scroll view's bottom inset, so there is no need to
// react to them again. Doing so appears to be harmless (through experimentation),
// but is hard to reason about.
return
}
updateForCurrentKeyboardVisibility()
}
/// Updates the view controller to compensate for the appearance or disappearance of
/// the keyboard or changes to the keyboard's size in response to a notification.
private func updateForKeyboardVisibilityNotification(_ notification: Notification) {
guard let keyboardFrameEvent = self.keyboardFrameEvent(from: notification),
let scrollView = delegate?.scrollView,
let scrollViewFilter = scrollViewFilter else {
return
}
// Suppress unwanted text field animation generated by UIKit.
suppressTextFieldTextAnimation()
// Instead of responding to the change in the keyboard frame by resizing the view
// immediately, filter sequences of changes so that only the final change is
// handled. This avoids unwanted animation.
scrollViewFilter.submitKeyboardFrameEvent(keyboardFrameEvent)
if notification.name == UIResponder.keyboardWillHideNotification
&& delegate?.shouldResizeContentViewForKeyboard == true
&& scrollView.keyboardDismissMode != .none
&& scrollView.isTracking {
// If the keyboard is being dismissed by way of a drag gesture and the content view
// is resizable, respond to the change in the keyboard's frame immediately, without
// the temporal filtering normally provided by KeyboardAdjustmentFilter, so the
// animation of the scroll view's frame change is handled within UIKit's animation
// block that wraps the keyboardWillHide notification. This results in more
// pleasing animation. The alternative, waiting until the KeyboardAdjustmentFilter
// timer fired, would result in an awkward jump of the scroll view's contents as
// the content view area was resized.
scrollViewFilter.flush()
} else if keyboardFrameEvent.isResultOfNavigationControllerTransition {
// If the keyboard is being presented because of a UINavigationController
// transition, flush the keyboard frame change filter immediately, without
// animation. If, instead, handling of the keyboard frame change is deferred, then
// if the user pops a view controller that has no visible keyboard to a view that
// has a visible keyboard, the layout of the view would change size halfway through
// the transition.
UIView.performWithoutAnimation {
scrollViewFilter.flush()
}
}
// Continues in adjustViewForKeyboard(withKeyboardFrameEvent:)...
}
/// Updates the view controller to compensate for the current state of the keyboard.
///
/// This method handles the case where a navigation controller pushes a view
/// controller while the keyboard is already visible, in which case UIKit will not
/// generate a notification. In this case, the last notification captured by
/// `KeyboardNotificationManager` is repeated.
private func updateForCurrentKeyboardVisibility() {
if let lastNotification = KeyboardNotificationManager.shared.lastNotification,
let keyboardFrameEvent = keyboardFrameEvent(from: lastNotification),
let scrollViewFilter = scrollViewFilter {
scrollViewFilter.submitKeyboardFrameEvent(keyboardFrameEvent)
// The keyboard frame event filter is flushed when it isn't suspended. Typically,
// at this point, it will be suspended during device orientation changes, and if
// the filter was flushed, jerky animation would result.
if !scrollViewFilter.isSuspended {
scrollViewFilter.flush()
}
// Continues in adjustViewForKeyboard(withKeyboardFrameEvent:)...
}
}
/// Tests submitting a keyboard frame event.
internal func testKeyboardFrameEvent(_ keyboardFrameEvent: KeyboardFrameEvent) {
// This method is intended for use in unit tests only.
assert(isUnitTest)
guard let scrollViewFilter = scrollViewFilter else {
return
}
scrollViewFilter.submitKeyboardFrameEvent(keyboardFrameEvent)
scrollViewFilter.flush()
// Continues in adjustViewForKeyboard(withKeyboardFrameEvent:)...
}
/// Suppresses unwanted text field text animation.
///
/// If the user taps a sequence of text fields, unwanted animation in the position
/// of the text within the text fields may occur. This method suppresses this
/// behavior by calling `layoutIfNeeded` within a `performWithoutAnimation` closure.
///
/// It appears that UIKit posts `UIResponder` keyboard notifications after updating
/// text fields within animation blocks.
private func suppressTextFieldTextAnimation() {
UIView.performWithoutAnimation {
delegate?.contentView?.layoutIfNeeded()
}
}
/// Returns a keyboard frame event, given a notification.
///
/// - Parameter notification: The `UIResponder` keyboard notification.
/// - Returns: The keyboard's frame.
private func keyboardFrameEvent(from notification: Notification) -> KeyboardFrameEvent? {
guard let userInfo = notification.userInfo,
let keyboardFrameEndUserInfoValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
let keyboardAnimationDurationNumber = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
let window = UIApplication.shared.keyWindow
else {
return nil
}
let keyboardFrame = keyboardFrameEndUserInfoValue.cgRectValue
let keyboardAnimationDuration = keyboardAnimationDurationNumber.doubleValue as TimeInterval
// From https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html#//apple_ref/doc/uid/TP40009542-CH5-SW3
// "Note: The rectangle contained in the UIKeyboardFrameBeginUserInfoKey and
// UIKeyboardFrameEndUserInfoKey properties of the userInfo dictionary should be
// used only for the size information it contains. Do not use the origin of the
// rectangle (which is always {0.0, 0.0}) in rectangle-intersection operations.
// Because the keyboard is animated into position, the actual bounding rectangle of
// the keyboard changes over time."
switch notification.name {
case UIResponder.keyboardWillHideNotification:
return KeyboardFrameEvent(
keyboardFrame: CGRect(x: 0, y: window.bounds.height, width: keyboardFrame.size.width, height: 0),
duration: keyboardAnimationDuration)
case UIResponder.keyboardWillShowNotification:
return KeyboardFrameEvent(
keyboardFrame: CGRect(x: 0, y: window.bounds.height - keyboardFrame.size.height, width: keyboardFrame.size.width, height: keyboardFrame.size.height),
duration: keyboardAnimationDuration)
default:
assertionFailure("Unexpected notification type")
return nil
}
}
}
extension KeyboardObserver: KeyboardNotificationObserving {
func didReceiveKeyboardNotification(_ notification: Notification) {
updateForKeyboardVisibilityNotification(notification)
}
}
extension KeyboardObserver: ScrollViewFilterKeyboardDelegate {
func scrollViewFilter(_ scrollViewFilter: ScrollViewFilter, adjustViewForKeyboardFrameEvent keyboardFrameEvent: KeyboardFrameEvent) {
isAdjustingViewForKeyboardFrameEvent = true
defer {
isAdjustingViewForKeyboardFrameEvent = false
}
guard let bottomInset = self.bottomInset(from: keyboardFrameEvent.keyboardFrame) else {
return
}
// Disable animation for UINavigationController push transitions. Otherwise, if the
// keyboard remains visible during the transition, this may result in unwanted
// animation of the size of the view above the keyboard as the new view controller
// is presented.
let animated = keyboardFrameEvent.isResultOfNavigationControllerTransition == false
func animations() {
self.delegate?.adjustViewForKeyboard(withBottomInset: bottomInset)
self.delegate?.hostViewController?.view.layoutIfNeeded()
}
func completion(_ finished: Bool) {
// Do nothing.
}
if animated {
UIView.animate(withDuration: bottomInsetAnimationDuration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: [], animations: animations, completion: completion)
} else {
animations()
completion(true)
}
assert(isAdjustingViewForKeyboardFrameEvent)
}
/// Returns the height of portion of the keyboard's frame that overlaps the scroll
/// view.
///
/// This method correctly handles the case where the view doesn't cover the entire
/// screen.
///
/// - Parameter notification: The keyboard notification that provides the keyboard's
/// frame.
/// - Returns: The height of portion of the keyboard's frame that overlaps the view.
private func bottomInset(from keyboardFrame: CGRect?) -> CGFloat? {
guard let keyboardFrame = keyboardFrame,
let hostViewController = delegate?.hostViewController,
let view = hostViewController.view,
// UIApplication.shared.keyWindow is nil when unit tests are executing, and
// view.window is nil outside of unit tests in the case when a view is being
// pushed.
let window = isUnitTest ? view.window : UIApplication.shared.keyWindow else {
return nil
}
// The frame of the view in the window's coordinate space.
let viewFrameInWindow = window.convert(view.frame, from: view.superview)
// The intersection of the keyboard's frame with the frame of the view in the
// window's coordinate space.
let keyboardViewIntersectionFrameInWindow = viewFrameInWindow.intersection(keyboardFrame)
// The intersection of the keyboard's frame with the frame of the view in the
// view's coordinate space.
let keyboardViewIntersectionFrameInView = window.convert(keyboardViewIntersectionFrameInWindow, to: view)
// The height of the region of the keyboard that overlaps the view.
let overlappingKeyboardHeight = keyboardViewIntersectionFrameInView.height
// The view's safe area bottom inset.
let safeAreaBottomInset = hostViewController.view.safeAreaInsets.bottom
// The view's additional safe area bottom inset.
let additionalSafeAreaBottomInset = hostViewController.additionalSafeAreaInsets.bottom
// The bottom safe area bottom inset, excluding the additional safe area inset.
// This is clamped to zero because in the case when another view controller is
// being popped and the destination view controller has not yet appeared, it seems
// that the value returned by safeAreaInsets does not take additionalSafeAreaInsets
// into account, which would otherwise result in a negative number.
let baseSafeAreaBottomInset = max(0, safeAreaBottomInset - additionalSafeAreaBottomInset)
// The height of area of the keyboard's frame that overlaps the view.
let keyboardHeightOverlappingView = max(0, overlappingKeyboardHeight - baseSafeAreaBottomInset)
return keyboardHeightOverlappingView
}
}
+36
View File
@@ -0,0 +1,36 @@
//
// KeyboardObservering.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// Delegate for `KeyboardObserver`.
internal protocol KeyboardObservering: class {
/// View controller over top of which the keyboard is presented.
var hostViewController: UIViewController? { get }
/// Content view that contains text fields.
var contentView: UIView? { get }
/// Scroll view that is the super view of `contentView`.
var scrollView: ScrollingContentScrollView { get }
/// If `true`, the content view should be resized to compensate for the portion of
/// the scroll view obscured by the presented keyboard, if possible.
var shouldResizeContentViewForKeyboard: Bool { get }
/// Adjusts the view to compensate for the portion of the keyboard that overlaps the
/// scroll view.
///
/// - Parameter bottomInset: The height of the vertical extent of the keyboard that
/// overlaps the scroll view.
func adjustViewForKeyboard(withBottomInset bottomInset: CGFloat)
}
+35
View File
@@ -0,0 +1,35 @@
//
// ScrollRectEvent.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/26/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import Foundation
/// An event encapsulating a deferred call to `scrollRectToVisible(_:animated:)`.
internal struct ScrollRectEvent {
enum ContentArea {
/// The content view should be scrolled to make visible a rectangle in the
/// coordinate space of the scroll view's content area.
case scrollViewRect(_ rect: CGRect)
/// The content view should be scrolled to make visible a rectangle in the
/// coordinate space of the bounds of a descendant view of the content view.
case descendantViewRect(_ rect: CGRect, descendantView: UIView)
}
/// The area of the scroll view's content to make visible.
var contentArea: ContentArea
/// `true` if the scrolling should be animated.
var animated: Bool
/// A margin that should be added to the content area.
var margin: CGFloat
}
+50
View File
@@ -0,0 +1,50 @@
//
// ScrollViewBounceController.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 12/27/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// An object that modifies the scroll view's `alwaysBounceVertical` property to
/// reflect the state of the presented keyboard. This ensures that when
/// `keyboardDismissMode` is set to `interactive` it will work as expected, even if
/// the content view is short enough that scrolling wouldn't normally be permitted.
internal class ScrollViewBounceController {
private weak var delegate: ScrollViewBounceControlling?
private var initialAlwaysBounceVertical: Bool?
init(delegate: ScrollViewBounceControlling) {
self.delegate = delegate
}
var bottomInset: CGFloat = 0 {
didSet {
guard let scrollView = delegate?.scrollView,
scrollView.keyboardDismissMode != .none else {
return
}
if bottomInset != 0 && oldValue == 0 {
// The keyboard was presented.
initialAlwaysBounceVertical = scrollView.alwaysBounceVertical
scrollView.alwaysBounceVertical = true
} else if bottomInset == 0 && oldValue != 0 {
// The keyboard was dismissed.
guard let initialAlwaysBounceVertical = initialAlwaysBounceVertical else {
assertionFailure()
return
}
scrollView.alwaysBounceVertical = initialAlwaysBounceVertical
self.initialAlwaysBounceVertical = nil
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
//
// ScrollViewBounceControlling.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// Delegate for `ScrollViewBounceController`.
internal protocol ScrollViewBounceControlling: class {
/// Scroll view whose `alwaysBounceVertical` property is manipulated.
var scrollView: ScrollingContentScrollView { get }
}
+266
View File
@@ -0,0 +1,266 @@
//
// ScrollViewFilter.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/24/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// An object that applies a temporal filter to keyboard frame change notifications
/// and `scrollRectToVisible` calls to avoid unwanted animation.
///
/// When a text field becomes the first responder, iOS presents the keyboard. If the
/// user taps another text field, changing the first responder, iOS may adjust the
/// keyboard's height if an input accessory view is specified. Often, these changes
/// will generate a sequence multiple of `keyboardWillShow` notifications, each with
/// different keyboard frame heights.
///
/// As an extreme example, if the user populates a text field by tapping on an
/// AutoFill input accessory view, and this action causes a password text field to
/// automatically become the first responder, one `keyboardWillHide` notifications
/// and two `keyboardWillShow` notifications will be posted within a span of 0.1
/// seconds.
///
/// If `KeyboardObserver` were to respond to each of these notifications
/// individually, this would result in awkward discontinuities in the scroll view
/// animation that accompanies changes to the keyboard's height.
///
/// To work around this issue, `ScrollViewFilter` filters out sequences of
/// notifications that occur within a small time window, acting only on the final
/// assigned keyboard frame in the sequence.
///
/// `ScrollViewFilter` also filters calls to `scrollRectToVisible`. If a text field
/// is the first responder when a device orientation change occurs, UIKit will call
/// `scrollRectToVisible` with the text field's frame at the end of the transition
/// at a time when `adjustedContentInset` hasn't yet been updated to reflect the
/// orientation change. This will result in the view scrolling unnnecessarily, or
/// worse, to a point beyond the legal scrolling extent of the scroll view. As a
/// workaround, `ScrollViewFilter` defers this call for a short period of time until
/// after `adjustedContentInset` has been updated.
///
/// Because `ScrollViewFilter` filters both keyboard notifications and
/// `scrollRectToVisible` calls, it is also able to handle a special case in which
/// the device orientation changes, resulting in a keyboard frame change
/// notification which coincides with a call to `scrollRectToVisible` which is
/// implicitly made by iOS. If only the keyboard notifications were filtered and
/// `scrollRectToVisible` calls were allowed to occur as originally scheduled, the
/// scroll view would awkwardly scroll up and down after the device orientation
/// change.
internal class ScrollViewFilter {
/// Delegate that is notified when a change in the keyboard's frame occurs.
weak var keyboardDelegate: ScrollViewFilterKeyboardDelegate?
/// Delegate that is notified when `scrollRectToVisible` should be executed.
weak var scrollDelegate: ScrollViewFilterScrollDelegate?
/// The delay before calls to `submitKeyboardFrameEvent` or
/// `submitKeyboardFrameEvent` will result in delegate calls. This value was chosen
/// to be slightly larger than the interval between successive keyboard frame
/// notifications that accompany device orientation changes. If a significantly
/// smaller value is chosen, the view will resize erratically during an orientation
/// change.
private let delay: TimeInterval = 0.1
/// The last submitted keyboard event.
private var keyboardFrameEvent: KeyboardFrameEvent?
/// The last submitted scroll rect event.
private var scrollRectEvent: ScrollRectEvent?
/// The timer used to apply the temporal filter.
private var timer: Timer?
/// The time that the timer started.
private var timerStartDate: Date?
/// The timer's time interval.
private var timerTimeInterval: TimeInterval?
/// This property is `true` when the temporal filtering is suspended by calling the
/// `suspend` method.
private(set) var isSuspended = false
/// This property is `true` if the timer was active when `suspend` was last called,
/// or if an attempt was made to start the timer while the filter was suspended.
private var shouldRestartTimerWhenResumed = false
/// The time remaining on the timer when it was suspended. This time interval will
/// be used when filtering is resumed and the timer is restarted.
private var suspendedTimerTimerInterval: TimeInterval = 0
deinit {
cancel()
}
/// Submits a keyboard frame event, which will result in a call to
/// `ScrollViewFilterKeyboardDelegate.scrollViewFilter(_:adjustViewForKeyboardFrameEvent:)`
/// after a short delay.
///
/// - Parameter keyboardFrameEvent: The keyboard frame event to submit.
func submitKeyboardFrameEvent(_ keyboardFrameEvent: KeyboardFrameEvent) {
self.keyboardFrameEvent = keyboardFrameEvent
startTimer(timeInterval: delay)
}
/// Submits a scroll rect event, which will result in a call to
/// `ScrollViewFilterScrollDelegate.scrollViewFilter(_:adjustViewForScrollRectEvent:)`
/// after a short delay.
///
/// - Parameter scrollRectEvent: The scroll rect event to submit.
func submitScrollRectEvent(_ scrollRectEvent: ScrollRectEvent) {
self.scrollRectEvent = scrollRectEvent
startTimer(timeInterval: delay)
}
/// Cancels the filter.
///
/// No delegate calls will be made until new events are submitted.
func cancel() {
invalidate()
shouldRestartTimerWhenResumed = false
suspendedTimerTimerInterval = 0
}
/// Immediately notifies the delegates of any pending events.
///
/// If no events are pending, this method has no effect. If the filter is suspended,
/// no action is taken, but pending events will be acted upon immediately when the
/// filter is resumed.
func flush() {
guard !isSuspended else {
// Fire the timer immediately when it is resumed.
suspendedTimerTimerInterval = 0
return
}
timer?.fire()
}
/// Suspends filtering.
///
/// The filter may be restarted by calling `resume`.
func suspend() {
guard !isSuspended else {
return
}
if timer != nil {
shouldRestartTimerWhenResumed = true
suspendedTimerTimerInterval = remainingTimerTimeInterval
} else {
shouldRestartTimerWhenResumed = false
suspendedTimerTimerInterval = 0
}
isSuspended = true
invalidate()
}
/// Resumes filtering that was suspended earlier.
func resume() {
guard isSuspended else {
return
}
isSuspended = false
if shouldRestartTimerWhenResumed {
shouldRestartTimerWhenResumed = false
startTimer(timeInterval: suspendedTimerTimerInterval)
suspendedTimerTimerInterval = 0
}
}
/// Invalidates the timer.
private func invalidate() {
timer?.invalidate()
timerStartDate = nil
timerTimeInterval = nil
}
/// Starts the timer that filters scroll view updates.
private func startTimer(timeInterval: TimeInterval) {
if isSuspended {
shouldRestartTimerWhenResumed = true
suspendedTimerTimerInterval = max(suspendedTimerTimerInterval, timeInterval)
return
}
// Constrain the remaining time interval so it can't get shorter.
let timeInterval = max(timeInterval, remainingTimerTimeInterval)
// This must be called after remainingTimerTimeInterval is referenced above, or
// else remainingTimerTimeInterval will always return zero.
cancel()
// Don't bother starting the timer if the interval would be zero.
if timeInterval == 0 {
callDelegatesIfNeeded()
return
}
timerStartDate = Date()
// This value must be stored separately because Timer.timeInterval, which ideally
// should be referenced below in remainingTimerTimeInterval, returns 0 for
// non-repeating timers.
self.timerTimeInterval = timeInterval
let timer = Timer(timeInterval: timeInterval, repeats: false, block: { [weak self] (timer: Timer) in
guard let self = self else {
return
}
self.callDelegatesIfNeeded()
// This is intentionally called after callDelegatesIfNeeded, not before, to allow
// for the case where the adjustViewForKeyboardFrameEvent delegate call results in
// a call to submitScrollRectEvent, which will restart the timer but which will
// also be handled immediately in callDelegatesIfNeeded.
self.invalidate()
})
self.timer = timer
// RunLoop.Mode.common must be used instead of the default run loop mode, because
// otherwise the timer will not fire while the scroll view is scrolling, which will
// be the case when the user swipes to dismiss the keyboard when the scroll view's
// keyboardDismissMode is set to interactive, in which case the keyboard frame will
// be adjusted by KeyboardObserver only after an extended delay.
RunLoop.current.add(timer, forMode: .common)
}
/// Calls the keyboard frame delegate and/or the scroll rect delegate, if needed.
private func callDelegatesIfNeeded() {
if let keyboardFrameEvent = keyboardFrameEvent {
self.keyboardFrameEvent = nil
keyboardDelegate?.scrollViewFilter(self, adjustViewForKeyboardFrameEvent: keyboardFrameEvent)
}
if let scrollRectEvent = scrollRectEvent {
// Note: It's possible that the call to adjustViewForKeyboardFrameEvent, above,
// results in a new call to submitScrollRectEvent which will be immediately handled
// here. The corresponding timer will be invalidated in the timer's closure in
// startTimer, above.
self.scrollRectEvent = nil
scrollDelegate?.scrollViewFilter(self, adjustViewForScrollRectEvent: scrollRectEvent)
}
}
/// The amount of time remaining on the previous timer, or zero if there is no active timer.
private var remainingTimerTimeInterval: TimeInterval {
guard timer != nil,
let timerTimeInterval = timerTimeInterval,
let timerStartDate = timerStartDate else {
return 0
}
return max(0, timerTimeInterval - Date().timeIntervalSince(timerStartDate))
}
}
@@ -0,0 +1,21 @@
//
// ScrollViewFilterKeyboardDelegate.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A protocol that an object implements to be notified by `ScrollViewFilter` about
/// keyboard frame changes.
internal protocol ScrollViewFilterKeyboardDelegate: class {
/// Adjusts the view to compensate for the portion of the keyboard that overlaps the
/// scroll view.
func scrollViewFilter(_ scrollViewFilter: ScrollViewFilter, adjustViewForKeyboardFrameEvent keyboardFrameEvent: KeyboardFrameEvent)
}
@@ -0,0 +1,21 @@
//
// ScrollViewFilterScrollDelegates.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/24/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A protocol that an object implements to be notified by `ScrollViewFilter` about
/// requests to scroll a specific area of the content so that it is visible in the
/// scroll view.
internal protocol ScrollViewFilterScrollDelegate: class {
/// Scrolls a specific area of the content so that it is visible in the scroll view.
func scrollViewFilter(_ scrollViewFilter: ScrollViewFilter, adjustViewForScrollRectEvent scrollRectEvent: ScrollRectEvent)
}
+170
View File
@@ -0,0 +1,170 @@
//
// ScrollingContentScrollView.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/13/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A scroll view that works with `ScrollingContentViewController` and
/// `ScrollingContentViewManager`.
///
/// See [https://github.com/drewolbrich/ScrollingContentViewController](https://github.com/drewolbrich/ScrollingContentViewController/blob/master/README.md) for full documentation.
public class ScrollingContentScrollView: UIScrollView {
// The implementation of `scrollRectToVisible` provided by
// `ScrollingContentScrollView` takes part in the temporal filtering of keyboard
// frame resizing events provided by `ScrollViewFilter`. Critically, this allows
// the execution of `scrollRectToVisible` to be delayed until the scroll view's
// content size and layout has been updated to reflect the size of the keyboard.
// Without this delay, the scroll view may not scroll by the correct amount, or may
// scroll beyond the valid range of the scroll view's content offset.
/// The margin applied when UIKit automatically scrolls the scroll view to make the
/// first responder visible in response to keyboard presentation or device
/// orientation changes.
///
/// The default value is 0, which matches the UIKit behavior.
///
/// This value is also applied when `scrollFirstResponderTextFieldToVisible`,
/// `scrollViewToVisible`, or `scrollRectToVisible` are called, unless overridden
/// with the optional `margin` parameter provided by those methods.
public var visibilityScrollMargin: CGFloat = 0
private weak var scrollViewFilter: ScrollViewFilter?
internal convenience init(scrollViewFilter: ScrollViewFilter) {
self.init()
self.scrollViewFilter = scrollViewFilter
scrollViewFilter.scrollDelegate = self
// The UIScrollView contentInsetAdjustmentBehavior property must be set to always.
// If it's left at its default value, automatic, then in the case when a scrolling
// content view controller is presented outside of the context of a navigation
// controller, changes to the size of the content view will result in the content
// view's safe area insets changing unpredictably. The always behavior is chosen
// here instead of never because unlike never, the always behavior adjusts the
// scroll indicator insets, which is desirable, in particular on iPhone XS in
// landscape orientation with the keyboard presented.
contentInsetAdjustmentBehavior = .always
}
public override func scrollRectToVisible(_ rect: CGRect, animated: Bool) {
scrollRectToVisible(rect, animated: animated, margin: nil)
}
/// Scrolls an area of the content view so it becomes visible.
///
/// Unlike the default `UIScrollView` implementation of `scrollRectToVisible`, the
/// scrolling does not take place immediately, but is submitted to
/// `ScrollViewFilter` for later processing.
///
/// Because `super.scrollRectToVisible` is not called immediately, it is possible
/// that the size and layout of the scroll view's content may have changed by the
/// time the `adjustViewForScrollRectEvent` method is called, below. Consequently,
/// this implementation of `scrollRectToVisible` makes an attempt to determine which
/// of the scroll view's descendants corresponds to the specified rectangle, if any.
/// When `adjustViewForScrollRectEvent` is called, the final rectangle is determined
/// relative to the bounds of that view. Without this approach, the scroll view may
/// scroll too far, and possibly beyond the valid content offset range of the scroll
/// view.
///
/// - Parameters:
/// - rect: The rectangular area to make visible.
/// - animated: `true` if the scrolling should be animated.
/// - margin: An optional margin around `rect` that should also be made visible.
/// If `nil`, the value of `visibilityScrollMargin` is used.
public func scrollRectToVisible(_ rect: CGRect, animated: Bool, margin: CGFloat? = nil) {
if let descendantView = self.descendentView(of: self, containing: rect, in: self) {
let rect = descendantView.convert(rect, from: self)
scrollViewFilter?.submitScrollRectEvent(ScrollRectEvent(contentArea: .descendantViewRect(rect, descendantView: descendantView), animated: animated, margin: margin ?? visibilityScrollMargin))
/// Continues in scrollViewFilter(_:adjustViewForScrollRectEvent:)...
return
}
// No appropriate descendant view could be found, so `rect` is assumed to be defined
// in the space of the scroll view's content area.
scrollViewFilter?.submitScrollRectEvent(ScrollRectEvent(contentArea: .scrollViewRect(rect), animated: animated, margin: margin ?? visibilityScrollMargin))
/// Continues in scrollViewFilter(_:adjustViewForScrollRectEvent:)...
}
/// Scrolls the scroll view to make the specified view visible.
///
/// - Parameters:
/// - view: The view to make visible.
/// - animated: If `true`, the scrolling is animated.
/// - margin: An optional margin to apply to the view. If left unspecified,
/// `scrollToVisibleMargin` is used.
public func scrollViewToVisible(_ view: UIView, animated: Bool, margin: CGFloat? = nil) {
scrollViewFilter?.submitScrollRectEvent(ScrollRectEvent(contentArea: .descendantViewRect(view.bounds, descendantView: view), animated: animated, margin: margin ?? visibilityScrollMargin))
/// Continues in scrollViewFilter(_:adjustViewForScrollRectEvent:)...
}
/// Scrolls the scroll view to make the first responder visible. If no first
/// responder is defined, this method has no effect.
///
/// - Parameters:
/// - animated: If `true`, the scrolling is animated.
/// - margin: An optional margin to apply to the first responder. If left
/// unspecified, `scrollToVisibleMargin` is used.
public func scrollFirstResponderToVisible(animated: Bool, margin: CGFloat? = nil) {
guard let view = UIResponder.rf_current as? UIView else {
return
}
scrollViewToVisible(view, animated: animated, margin: margin)
}
/// Returns the descedant view with the greatest depth whose bounds contains the
/// specified rectangle.
///
/// - Parameters:
/// - view: The view from which the search should start.
/// - rect: The rectangle to search for, defined in the coordinate space of `rectView`.
/// - rectView: The view that defines the coordinate space in which `rect` is defined.
/// - Returns: The descendant view that contains `rect`.
private func descendentView(of view: UIView, containing rect: CGRect, in rectView: UIView) -> UIView? {
let frame = rectView.convert(rect, to: view)
for subview in view.subviews {
// Perform a depth first search so the descendant view with the greatest depth that
// contains the rectangle will be found first.
if let descendentView = descendentView(of: subview, containing: rect, in: rectView) {
return descendentView
}
if subview.frame.contains(frame) {
return subview
}
}
return nil
}
}
extension ScrollingContentScrollView: ScrollViewFilterScrollDelegate {
internal func scrollViewFilter(_ scrollViewFilter: ScrollViewFilter, adjustViewForScrollRectEvent scrollRectEvent: ScrollRectEvent) {
var scrollViewRect: CGRect = .zero
switch scrollRectEvent.contentArea {
case .scrollViewRect(let rect):
scrollViewRect = rect
break
case .descendantViewRect(let rect, let descendantView):
scrollViewRect = convert(rect, from: descendantView)
break
}
scrollViewRect = scrollViewRect.insetBy(dx: 0, dy: -scrollRectEvent.margin)
super.scrollRectToVisible(scrollViewRect, animated: scrollRectEvent.animated)
}
}
+21
View File
@@ -0,0 +1,21 @@
//
// ScrollingContentViewController.h
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
#import <UIKit/UIKit.h>
//! Project version number for ScrollingContentViewController.
FOUNDATION_EXPORT double ScrollingContentViewControllerVersionNumber;
//! Project version string for ScrollingContentViewController.
FOUNDATION_EXPORT const unsigned char ScrollingContentViewControllerVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <ScrollingContentViewController/PublicHeader.h>
+132
View File
@@ -0,0 +1,132 @@
//
// ScrollingContentViewController.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A view controller that manages a single scrolling content view.
///
/// `ScrollingContentViewController` is a subclass of `UIViewController` that
/// provides a `contentView` outlet which can be assigned in Interface Builder or
/// programmatically. The width and height of the content view are constrained to be
/// greater than or equal to the dimensions of the root view's safe area. If the
/// content view's Auto Layout constraints or intrinsic content size require it to
/// exceed the size of the safe area, the content view will scroll freely.
///
/// The view controller's root view acts as a background view. Scrolling content
/// should be added to `contentView` instead of `view`.
///
/// The scroll view that hosts the content view is exposed via the `scrollView`
/// property.
///
/// See [https://github.com/drewolbrich/ScrollingContentViewController](https://github.com/drewolbrich/ScrollingContentViewController/blob/master/README.md) for full documentation.
open class ScrollingContentViewController: UIViewController {
/// The scrolling content view.
///
/// This view is the subview of `scrollView`.
@IBOutlet public var contentView: UIView! {
didSet {
if !isViewLoaded {
// If the view controller's root view hasn't been loaded yet, the assignment of
// scrollingContentViewManager.contentView must be deferred until viewDidLoad is
// called, because otherwise, no view hierarchy will exist to parent the content
// view and the scroll view to. This is the code path that is executed when the
// contentView outlet value defined in Interface Builder is assigned.
} else {
scrollingContentViewManager.contentView = contentView
}
}
}
/// The `UIScrollView` to which `contentView` is parented.
///
/// This view is typically the subview of `ScrollingContentViewController.view`, but
/// it may be an arbitrary descendant of that view.
public var scrollView: ScrollingContentScrollView {
return scrollingContentViewManager.scrollView
}
/// If `true`, the content view should be resized to compensate for the portion of
/// the scroll view obscured by the presented keyboard, if possible.
///
/// The default value is `false`.
@IBInspectable public var shouldResizeContentViewForKeyboard: Bool {
set {
scrollingContentViewManager.shouldResizeContentViewForKeyboard = newValue
}
get {
return scrollingContentViewManager.shouldResizeContentViewForKeyboard
}
}
/// If `true`, the view controller's `additionalSafeAreaInsets` property is adjusted
/// when the keyboard is presented.
///
/// The default value is `true`.
@IBInspectable public var shouldAdjustAdditionalSafeAreaInsetsForKeyboard: Bool {
set {
scrollingContentViewManager.shouldAdjustAdditionalSafeAreaInsetsForKeyboard = newValue
}
get {
return scrollingContentViewManager.shouldAdjustAdditionalSafeAreaInsetsForKeyboard
}
}
/// An object that manages adding a content view to a scroll view.
private lazy var scrollingContentViewManager = ScrollingContentViewManager(hostViewController: self)
/// If you override this method, you must call `super` at some point in your
/// implementation.
open override func loadView() {
// Load all controls and connect all outlets defined by Interface Builder.
super.loadView()
scrollingContentViewManager.loadView(forContentView: contentView)
}
/// If you override this method, you must call `super` at some point in your
/// implementation.
open override func viewDidLoad() {
// If the content view has been assigned by Interface Builder, add it and the
// scroll view to the view hierarchy automatically. Otherwise, it is the caller's
// responsibility to manually assign the `contentView` property in their own
// implementation of viewDidLoad.
if let contentView = contentView {
scrollingContentViewManager.contentView = contentView
}
}
/// If you override this method, you must call `super` at some point in your
/// implementation.
open override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
assert(contentView != nil, "Either contentView must be assigned in viewDidLoad, or the contentView outlet must be connected in Interface Builder")
assert(scrollingContentViewManager.contentView != nil, "The content view was not added to the view hierarchy. Did you forget to call super in viewDidLoad?")
}
/// If you override this method, you must call `super` at some point in your
/// implementation.
open override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
scrollingContentViewManager.viewSafeAreaInsetsDidChange()
}
/// If you override this method, you must call `super` at some point in your
/// implementation.
open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
scrollingContentViewManager.viewWillTransition(to: size, with: coordinator)
}
}
+536
View File
@@ -0,0 +1,536 @@
//
// ScrollingContentViewManager.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A helper class that supports a view controller that manages a single scrolling
/// content view.
///
/// `ScrollingContentViewController` is implemented in terms of
/// `ScrollingContentViewManager`. In situations where
/// `ScrollingContentViewController` cannot be subclassed,
/// `ScrollingContentViewManager` may be used instead.
///
/// See [https://github.com/drewolbrich/ScrollingContentViewController](https://github.com/drewolbrich/ScrollingContentViewController/blob/master/README.md) for full documentation.
public class ScrollingContentViewManager: KeyboardObservering, ScrollViewBounceControlling, AdditionalSafeAreaInsetsControlling {
/// The view controller that hosts the scroll view.
public private(set) weak var hostViewController: UIViewController?
/// The scrolling content view.
///
/// When the content view is first assigned, it is parented to a scroll view, which is
/// added to the host view controller's view hierarchy.
///
/// If the content view has a superview, the scroll view replaces it in the view
/// hierarchy and all of the superview's constraints that reference the content view
/// are retargeted to the scroll view. The content view's width and height
/// constraints and autoresizing mask are transferred to the scroll view.
///
/// If the content view has no superview, the scroll view is parented to the host
/// view controller's view and it's frame and autoresizing mask are defined to track
/// its bounds.
public var contentView: UIView? {
didSet {
if contentView == oldValue {
return
}
assert(contentView != nil, "The content view must not be nil")
if oldValue == nil {
// This is the first time contentView has been assigned. Add both the scroll view
// and the content view to the view hierarchy.
addScrollViewAndContentView()
} else {
// A contentView has already been assigned. Replace it with the new content view.
// Only a single content view is supported, so the scroll view's existing content
// view, if any, must first be removed. This also removes the existing scroll view
// constraints.
oldValue?.removeFromSuperview()
addContentView()
}
}
}
/// An object that applies a temporal filter to keyboard frame change notifications
/// and `scrollRectToVisible` calls to avoid unwanted animation artifacts.
private let scrollViewFilter = ScrollViewFilter()
/// The scroll view to which `contentView` is parented. This view is the subview
/// of `hostViewController.view`.
public lazy var scrollView = ScrollingContentScrollView(scrollViewFilter: scrollViewFilter)
/// If `true`, the content view should be resized to compensate for the portion of
/// the scroll view obscured by the presented keyboard, if possible.
///
/// The default value is `false`.
public var shouldResizeContentViewForKeyboard = false
/// If `true`, the view controller's `additionalSafeAreaInsets` property is adjusted
/// when the keyboard is presented.
///
/// The default value is `true`.
public var shouldAdjustAdditionalSafeAreaInsetsForKeyboard = true
/// A constraint that enforces a minimum width for the content view.
///
/// The priority of this constraint is `defaultHigh`.
internal private(set) var contentViewMinimumWidthConstraint: NSLayoutConstraint?
/// A constraint that enforces a minimum height for the content view.
///
/// The priority of this constraint is `defaultHigh`. This constraint's constant is
/// modified by `AdditionalSafeAreaInsetsController`.
internal private(set) var contentViewMinimumHeightConstraint: NSLayoutConstraint?
/// An object that responds to notifications posted by UIKit when the keyboard is
/// presented or dismissed, and which adjusts the scroll view to compensate.
///
/// This property's access control level is `internal` so it can be accessed by unit
/// tests.
internal(set) var keyboardObserver: KeyboardObserver?
/// An object that modifies the scroll view's `alwaysBounceVertical` property to
/// reflect the state of the presented keyboard.
///
/// This ensures that when `keyboardDismissMode` is set to `interactive` it will
/// work as expected, even if the content view is short enough to not require
/// scrolling.
private lazy var scrollViewBounceController = ScrollViewBounceController(delegate: self)
/// An object that adjusts the container view's `additionalSafeAreaInsets.bottom`
/// property to compensate for the portion of the keyboard that overlaps the scroll
/// view.
private lazy var additionalSafeAreaInsetsController = AdditionalSafeAreaInsetsController(delegate: self)
/// The priority of the content view's minimum width and height constaints.
///
/// The value 500 was chosen so that when one or more constraints with priority
/// `defaultLow` (250) are used along a particular axis, the content view will
/// stretch to fill the scroll view's safe area. If all constraints along a
/// particular axis have priority `defaultHigh` (750) or `required` (1000), they
/// will be given priority, and the content view will not stretch to fill the scroll
/// view's safe area.
///
/// In a content view's layout, it may be advantageous to include one constraint
/// with priority 240, because this value is lower than the default content hugging
/// priority (250) and consequently, it will help avoid the undesirable behavior
/// where text fields and labels without height constraints stretch vertically.
///
/// If, instead of constraints, `intrinsicContentSize` is used to define the size of
/// the content view, then the content view will stretch to fill the scroll view's
/// safe area because the content view's default content hugging priority is
/// `defaultLow` (250), which is less than the minimum size constraint priority. The
/// content view's content hugging priority may optionally be set to `defaultHigh`
/// (750), in which case the content view will not stretch to fill the scroll view's
/// safe area.
private let minimumSizeConstraintPriority = UILayoutPriority(rawValue: 500)
/// Returns a scrolling content view manager.
///
/// - Parameters hostViewController: The view controller that will host the scroll
/// view and its content view.
public init(hostViewController: UIViewController) {
self.hostViewController = hostViewController
keyboardObserver = KeyboardObserver(scrollViewFilter: scrollViewFilter, delegate: self)
}
/// Handles an edge case relating to keyboard visibility when
/// ScrollingContentViewController is used in conjunction with other view
/// controllers that present the keyboard in the context of a navigation controller.
///
/// The KeyboardNotificationManager singleton handles the case where a navigation
/// controller pushes a view controller while the keyboard is already visible,
/// making the keyboard's frame available to the newly pushed view controller.
/// However, it can only do this if KeyboardNotificationManager is already active
/// before the keyboard is presented.
///
/// To handle the edge case when the keyboard is already visible when the first view
/// controller managed by ScrollingContentViewController is pushed by a navigation
/// controller, this method can be called by the app's delegate's
/// `application(_:didFinishLaunchingWithOptions:)` method to ensure that the
/// KeyboardNotificationManager singleton is able to observe the full history of the
/// keyboard's frame.
///
/// - Parameters:
/// - application: The singleton app object.
/// - launchOptions: The dictionary indicating the reason the app was launched.
/// - Returns: Always returns `true`.
class func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
_ = KeyboardNotificationManager.shared
return true
}
/// This method should be called in the view controller's `loadView` method after
/// `super.loadView` is called.
///
/// In the case that, in `Interface Builder`, the host view controller's
/// `contentView` outlet is assigned to its root view, this method replaces the root
/// view with a newly created view. Consequently, when `contentView` is later
/// assigned, the view controller will have a valid view to parent `scrollView` to,
/// without creating a cycle in the view hierarchy.
///
/// If the host view controller's `contentView` is not assigned to its root view,
/// this method has no effect.
///
/// - Parameter contentView: The content view to compare with the root view.
public func loadView(forContentView contentView: UIView?) {
guard let hostViewController = hostViewController else {
return
}
if hostViewController.view == contentView {
hostViewController.view = defaultRootView
}
}
/// A root view that is substituted for the content view in the case that the
/// content view and the root view are the same.
private lazy var defaultRootView: UIView = {
let rootView = UIView()
// By default, UIView.backgroundColor is nil, which in the general case would allow
// black pixels to be seen behind the view, so here it is changed to white, which
// is the default for UIViewController root views created by Interface Builder.
rootView.backgroundColor = .white
return rootView
}()
/// Adds an initial content view as a subview of the scroll view.
///
/// If the content view has a superview, the scroll view replaces it in the view
/// hierarchy and all of the superview's constraints that reference the content view
/// are retargeted to the scroll view. The content view's width and height
/// constraints and autoresizing mask are transferred to the scroll view.
///
/// If the content view has no superview, the scroll view is parented to the host
/// view controller's view and it's frame and autoresizing mask are defined to track
/// its bounds.
///
/// This method may only be called once. To replace an existing content view
/// with a new one, call `addContentView`.
private func addScrollViewAndContentView() {
assert(scrollView.superview == nil, "addScrollViewAndContentView may only be called once")
assert(contentView !== hostViewController?.view, "When the content view is assigned, it must not be the host view controller's root view. If you are subclassing ScrollingContentViewController, call super.loadView first in loadView, or, in the case of ScrollingContentViewManager, call loadView(forContentView:) after super.loadView in loadView, or assign a root view of your own.")
guard let contentView = contentView else {
assertionFailure("The content view is undefined")
return
}
let contentViewSystemLayoutSize = contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
let contentViewIntrinsicContentSize = contentView.intrinsicContentSize
if contentViewSystemLayoutSize.width <= 0 && contentViewIntrinsicContentSize.width == UIView.noIntrinsicMetric {
NSLog("Warning: The content view's width is undefined. You must have an unbroken chain of constraints and views stretching from the content views left edge to its right edge or the content view's intrinsic content size must be defined.")
}
if contentViewSystemLayoutSize.height <= 0 && contentViewIntrinsicContentSize.height == UIView.noIntrinsicMetric {
NSLog("Warning: The content view's height is undefined. You must have an unbroken chain of constraints and views stretching from the content views top edge to its bottom edge or the content view's intrinsic content size must be defined.")
}
if contentView.superview == nil {
addScrollViewToHostViewControllerRootView()
} else {
insertScrollViewAsSuperviewOfContentView()
}
addContentView()
}
/// Replaces a content view with the scroll view.
///
/// The scroll view is assigned the content view's frame and autoresizing mask. The
/// constraints of the content view's superview that target the content view are
/// retargeted to the scroll view. The width and heights of the content view are
/// copied to the scroll view.
private func insertScrollViewAsSuperviewOfContentView() {
assert(scrollView.superview == nil, "Either of addContentView or insertScrollViewAsSuperview may only be called once")
guard let contentView = contentView else {
assertionFailure("The content view is undefined")
return
}
scrollView.translatesAutoresizingMaskIntoConstraints = contentView.translatesAutoresizingMaskIntoConstraints
scrollView.autoresizingMask = contentView.autoresizingMask
contentView.autoresizingMask = []
scrollView.frame = contentView.frame
guard let superview = contentView.superview else {
assertionFailure("The content view has no superview")
return
}
if let rootView = hostViewController?.view {
assert(contentView.isDescendant(of: rootView), "The content view is not a descendant of the host view controller's root view")
}
superview.insertSubview(scrollView, belowSubview: contentView)
redirectConstraints(of: superview, fromItem: contentView, toItem: scrollView)
// The width and height constraints are transferred from the content view to the
// scroll view, leaving the content view without width and height constraints. The
// content view will be assigned replacement minimum width and height constraints
// later, in `addScrollViewAndContentViewConstraints`.
transferWidthAndHeightConstraints(of: contentView, to: scrollView)
}
/// Adds the content view as a subview of the scroll view.
private func addContentView() {
// This assertion is nonviable because scrollView.subviews includes the scroll
// view's two scroll indicator image views.
#if false
assert(scrollView.subviews.isEmpty, "Only one content view may be parented to the scroll view")
#endif
guard let contentView = contentView else {
assertionFailure("The content view is undefined")
return
}
scrollView.addSubview(contentView)
addScrollViewAndContentViewConstraints()
}
/// Responds to changes in the view controller's safe area insets. If this method is
/// not called, then, in the context of a navigation controller, if a sequence of
/// view controllers with text fields that become the first responder in
/// `viewWillAppear` is pushed, the content view will not be sized correctly.
public func viewSafeAreaInsetsDidChange() {
keyboardObserver?.viewSafeAreaInsetsDidChange()
}
/// Responds to changes in the size of the view, for example in response to device
/// orientation changes, by adjusting the scroll view's content offset to ensure
/// that it falls within a legal range.
///
/// If the view controller responds to size changes (for example, resulting from
/// changes in device orientation), then this method must be called by the view
/// controller's implementation of `viewWillTransition(to:with:)`.
public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
let initialAdjustedContentInset = scrollView.adjustedContentInset
let initialContentOffset = scrollView.contentOffset
// When the device orientation changes, a keyboardWillHide notification is posted,
// followed by a keyboardDidShow notification only after the device orientation
// animation completes. If these were responded to immediately, this would result
// in awkward view resizing animation. To work around this issue, the
// KeyboardObserver's ScrollViewFilter is suspended during the transition, and as a
// result, only final size of the keyboard after the animation completes is acted
// upon.
keyboardObserver?.suspend()
coordinator.animate(alongsideTransition: { (context: UIViewControllerTransitionCoordinatorContext) in
var contentOffset = initialContentOffset
// At this point, if the keyboard is presented, it would be nice to keep the first
// responder continuously visible on the screen during the transition. However, it
// appears that there's no way to know what the new size of the keyboard will be,
// and by extension, the new size of the visible portion of the scroll view, which
// would be necessary to accurately maintain the first responder's visibility. A
// survey of iOS 12's apps (e.g. creating a new event in Calendar, or editing a
// document in Pages) reveals that Apple doesn't attempt to handle this case
// elegantly either.
// Pin the top left corner of the view. This matches the general behavior of
// Apple's iOS apps.
contentOffset = CGPoint(
x: contentOffset.x + initialAdjustedContentInset.left - self.scrollView.adjustedContentInset.left,
y: contentOffset.y + initialAdjustedContentInset.top - self.scrollView.adjustedContentInset.top)
self.scrollView.contentOffset = self.constrainScrollViewContentOffset(contentOffset)
}, completion: { (context: UIViewControllerTransitionCoordinatorContext) in
if self.keyboardObserver?.isSuspended == true {
self.keyboardObserver?.resume()
}
})
}
/// Redirects a host view's existing constraints from one item to another item.
///
/// - Parameters:
/// - hostView: View whose constraints to modify.
/// - fromItem: Item to transfer constraint references from.
/// - toItem: Item to transfer constraint references to.
private func redirectConstraints(of hostView: UIView, fromItem: AnyObject, toItem: AnyObject) {
let constraints = hostView.constraints
for constraint in constraints {
if let firstItem = (constraint.firstItem === fromItem) ? toItem : constraint.firstItem,
let secondItem = (constraint.secondItem === fromItem) ? toItem : constraint.secondItem,
firstItem !== constraint.firstItem || secondItem !== constraint.secondItem {
hostView.removeConstraint(constraint)
hostView.addConstraint(NSLayoutConstraint(item: firstItem, attribute: constraint.firstAttribute, relatedBy: constraint.relation, toItem: secondItem, attribute: constraint.secondAttribute, multiplier: constraint.multiplier, constant: constraint.constant))
}
}
}
/// Transfers width and height constraints from one view to another view.
///
/// - Parameters:
/// - fromView: The view to transfer width and height constraints from.
/// - toView: The view to transfer width and height constraints to.
private func transferWidthAndHeightConstraints(of fromView: UIView, to toView: UIView) {
var constraintsToRemove: [NSLayoutConstraint] = []
for constraint in fromView.constraints {
if constraint.firstAttribute == .width || constraint.firstAttribute == .height {
if let firstItem = (constraint.firstItem === fromView) ? toView : constraint.firstItem {
let secondItem = (constraint.secondItem === fromView) ? toView : constraint.secondItem
if firstItem === toView || secondItem === toView {
toView.addConstraint(NSLayoutConstraint(item: firstItem, attribute: constraint.firstAttribute, relatedBy: constraint.relation, toItem: secondItem, attribute: constraint.secondAttribute, multiplier: constraint.multiplier, constant: constraint.constant))
constraintsToRemove.append(constraint)
}
}
}
}
fromView.removeConstraints(constraintsToRemove)
}
/// Adds the scroll view as a subview of the host view controller's root view.
///
/// The scroll view's frame and autoresizing mask are defined to track the host view
/// controller's root view's bounds.
private func addScrollViewToHostViewControllerRootView() {
assert(scrollView.superview == nil)
guard let hostViewController = hostViewController else {
assertionFailure("The host view controller is undefined")
return
}
assert(hostViewController.view != nil, "The host view controller's root view is undefined")
hostViewController.view.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = true
scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
scrollView.frame = hostViewController.view.bounds
}
/// Constrains the content view to the scroll view's content layout guide,
/// and adds content view width and height constraints.
private func addScrollViewAndContentViewConstraints() {
// See https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/WorkingwithScrollViews.html
guard let contentView = contentView else {
assertionFailure("The content view is undefined")
return
}
// The relation greaterThanOrEqualTo is used for the minimumum width and height
// constraints so the content view is free to stretch to fill the scroll view's
// safe area.
let contentViewMinimumWidthConstraint = contentView.widthAnchor.constraint(greaterThanOrEqualTo: scrollView.safeAreaLayoutGuide.widthAnchor, multiplier: 1)
self.contentViewMinimumWidthConstraint = contentViewMinimumWidthConstraint
let contentViewMinimumHeightConstraint = contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.safeAreaLayoutGuide.heightAnchor, multiplier: 1)
self.contentViewMinimumHeightConstraint = contentViewMinimumHeightConstraint
contentViewMinimumWidthConstraint.priority = minimumSizeConstraintPriority
contentViewMinimumHeightConstraint.priority = minimumSizeConstraintPriority
contentView.translatesAutoresizingMaskIntoConstraints = false
let constraints: [NSLayoutConstraint] = [
scrollView.contentLayoutGuide.leftAnchor.constraint(equalTo: contentView.leftAnchor),
scrollView.contentLayoutGuide.rightAnchor.constraint(equalTo: contentView.rightAnchor),
scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: contentView.topAnchor),
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
contentViewMinimumWidthConstraint,
contentViewMinimumHeightConstraint,
]
scrollView.addConstraints(constraints)
}
/// Constrains a scroll view content offset so that it lies within the legal range
/// of possible values for the rest state of the scroll view.
///
/// - Parameter contentOffset: The content offset to constrain.
/// - Returns: The constrained content offset.
private func constrainScrollViewContentOffset(_ contentOffset: CGPoint) -> CGPoint {
var contentOffset = contentOffset
let contentSize = scrollView.contentSize
let visibleContentSize = self.visibleContentSize(of: scrollView)
let adjustedContentInset = scrollView.adjustedContentInset
// Don't let the scroll view scroll up past its right/bottom extent. If this isn't
// done, then if the view is shorter than the scroll view in portrait orientation,
// and the user scrolls to the bottom in landscape orientation, and then changes
// the orientation back to portrait, the top of the view will be permanently
// shifted up off the top of the screen, and there will no way for the user to
// scroll up to see it.
contentOffset.x = min(contentOffset.x, contentSize.width - visibleContentSize.width - adjustedContentInset.left)
contentOffset.y = min(contentOffset.y, contentSize.height - visibleContentSize.height - adjustedContentInset.top)
// Don't let the scroll view scroll down past its left/top extent. This isn't
// strictly necessary because, above, the top left corner of the view is pinned,
// but it supports possible future changes to how the content offset is managed.
contentOffset.x = max(contentOffset.x, -adjustedContentInset.left)
contentOffset.y = max(contentOffset.y, -adjustedContentInset.top)
return contentOffset
}
/// The size of the region of the scroll view in which content is visible. This is
/// size of the scroll view's bounds after its adjusted content inset has been
/// applied.
private func visibleContentSize(of scrollView: UIScrollView) -> CGSize {
return scrollView.bounds.inset(by: scrollView.adjustedContentInset).size
}
/// Adjusts the view to compensate for the portion of the keyboard that overlaps the
/// scroll view.
///
/// This method is called by `KeyboardObserver` when the keyboard is presented,
/// dismissed, or changes size.
///
/// - Parameter bottomInset: The height of the area of keyboard's frame that
/// overlaps the view.
func adjustViewForKeyboard(withBottomInset bottomInset: CGFloat) {
self.bottomInset = bottomInset
}
/// The bottom inset to assign to the view controller's additional safe area
/// to compensate for the area of the keyboard that overlaps the scroll view.
private var bottomInset: CGFloat = 0 {
didSet {
if bottomInset == oldValue {
return
}
scrollViewBounceController.bottomInset = bottomInset
if shouldAdjustAdditionalSafeAreaInsetsForKeyboard {
// When the keyboard is presented, the view controller's
// additionalSafeAreaInsets.bottom property us adjusted to compensate.
//
// This approach is chosen instead of resizing the scroll view's content size,
// because doing so requires adjusting its scrollIndicatorInsets property to
// compensate, and on iPhone XS in landscape orientation, this has the unfortunate
// side effect of awkwardly shifting the scroll indicator away from the edge of the
// screen.
//
// Additionally, the approach of resizing the scroll view's content size appears to
// interact poorly with the scroll view's scrollRectToVisible method.
additionalSafeAreaInsetsController.bottomInset = bottomInset
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
//
// UIResponder+Current.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 12/29/18.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
// Based on the Stack Overflow answer https://stackoverflow.com/a/52823735/2419404
// by MarqueIV https://stackoverflow.com/users/168179/marqueiv
// Licensed under the terms of the Attribution-ShareAlike 3.0 Unported license
// https://creativecommons.org/licenses/by-sa/3.0/
//
import UIKit
private var foundFirstResponder: UIResponder? = nil
internal extension UIResponder {
/// The current first responder.
static var rf_current: UIResponder? {
UIApplication.shared.sendAction(#selector(UIResponder.storeFirstResponder(_:)), to: nil, from: nil, for: nil)
defer {
foundFirstResponder = nil
}
return foundFirstResponder
}
@objc private func storeFirstResponder(_ sender: AnyObject) {
foundFirstResponder = self
}
}
+92
View File
@@ -0,0 +1,92 @@
//
// CodeTests.swift
// ScrollingContentViewControllerTests
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import XCTest
import ScrollingContentViewController
/// Test case of using `ScrollingContentViewController` in code.
class CodeTests: XCTestCase {
var window: UIWindow!
var scrollingContentViewController: ScrollingContentViewController!
var contentView: ContentView!
var scrollView: UIScrollView!
var rootView: UIView!
override func setUp() {
window = UIWindow(frame: UIScreen.main.bounds)
window.isHidden = false
scrollingContentViewController = ScrollingContentViewController()
contentView = ContentView()
scrollingContentViewController.contentView = contentView
scrollingContentViewController.beginAppearanceTransition(true, animated: false)
window.rootViewController = scrollingContentViewController
scrollingContentViewController.view.layoutIfNeeded()
scrollingContentViewController.endAppearanceTransition()
scrollView = scrollingContentViewController.scrollView
rootView = scrollingContentViewController.view
}
override func tearDown() {
scrollingContentViewController.beginAppearanceTransition(false, animated: false)
window.rootViewController = nil
scrollingContentViewController.endAppearanceTransition()
window.isHidden = true
window = nil
scrollingContentViewController = nil
scrollView = nil
rootView = nil
contentView = nil
}
/// Tests that the view hierarchy has the expected topology.
func testViewHierarchy() {
// The content view's superview should be the scroll view.
XCTAssertEqual(contentView.superview, scrollView)
// The scroll view's superview should be the view controller's root view.
XCTAssertEqual(scrollView.superview, rootView)
}
/// Tests that the content view and the scroll view have the expected size.
func testDefaultLayout() {
let rootViewSafeAreaSize = rootView.bounds.inset(by: rootView.safeAreaInsets).size
// The content view's frame should match the size of the root view's safe area.
XCTAssertEqual(contentView.frame.size, rootViewSafeAreaSize)
// The scroll view's content size should match that of the root view's safe area.
XCTAssertEqual(scrollView.contentSize, rootViewSafeAreaSize)
}
/// Tests that the size of the content view is the expected size for the case of a
/// view that's taller than root view's safe area.
func testExpandedLayout() {
contentView.heightConstraint.constant = 2000
scrollView.setNeedsLayout()
scrollView.layoutIfNeeded()
// The content view should stretch to larger than the root view's safe area.
XCTAssertEqual(contentView.frame.size.height, contentView.heightConstraint.constant)
// The scroll view's content size height should match that of the content view.
XCTAssertEqual(scrollView.contentSize.height, contentView.heightConstraint.constant)
}
}
+49
View File
@@ -0,0 +1,49 @@
//
// ContentView.swift
// ScrollingContentViewControllerTests
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A view assigned `contentView` property in StoryboardTests.storyboard.
class ContentView: UIView {
/// A constraint that determines the view's width.
var widthConstraint: NSLayoutConstraint!
/// A constraint that determine the view's height. This constraint's constant is
/// manipulated externally to test the behavior of views of varying heights.
var heightConstraint: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
translatesAutoresizingMaskIntoConstraints = false
widthConstraint = widthAnchor.constraint(equalToConstant: 200)
heightConstraint = heightAnchor.constraint(equalToConstant: 200)
// The priority of these constraints must be low, or otherwise they would have a
// priority of `required` (the default value for NSLayoutConstraint.priority), and
// would therefore take precedence over the `defaultHigh` constraints that
// ScrollingContentViewManager adds to determine the size of the content view.
widthConstraint.priority = .defaultLow
heightConstraint.priority = .defaultLow
addConstraints([widthConstraint, heightConstraint])
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
+33
View File
@@ -0,0 +1,33 @@
//
// IntrinsicSizeContentView.swift
// ScrollingContentViewControllerTests
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A view whose size is specified using the `intrinsicContentSize` property instead
/// of using constraints.
class IntrinsicSizeContentView: UIView {
var width: CGFloat = 200 {
didSet {
invalidateIntrinsicContentSize()
}
}
var height: CGFloat = 200 {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
return CGSize(width: width, height: height)
}
}
+92
View File
@@ -0,0 +1,92 @@
//
// IntrinsicSizeTests.swift
// ScrollingContentViewControllerTests
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import XCTest
import ScrollingContentViewController
/// Test case of specifying a content view size using `intrinsicContentSize`.
class IntrinsicSizeTests: XCTestCase {
var window: UIWindow!
var scrollingContentViewController: ScrollingContentViewController!
var intrinsicSizeContentView: IntrinsicSizeContentView!
var scrollView: UIScrollView!
var rootView: UIView!
override func setUp() {
window = UIWindow(frame: UIScreen.main.bounds)
window.isHidden = false
scrollingContentViewController = ScrollingContentViewController()
intrinsicSizeContentView = IntrinsicSizeContentView()
scrollingContentViewController.contentView = intrinsicSizeContentView
scrollingContentViewController.beginAppearanceTransition(true, animated: false)
window.rootViewController = scrollingContentViewController
scrollingContentViewController.view.layoutIfNeeded()
scrollingContentViewController.endAppearanceTransition()
scrollView = scrollingContentViewController.scrollView
rootView = scrollingContentViewController.view
}
override func tearDown() {
scrollingContentViewController.beginAppearanceTransition(false, animated: false)
window.rootViewController = nil
scrollingContentViewController.endAppearanceTransition()
window.isHidden = true
window = nil
scrollingContentViewController = nil
scrollView = nil
rootView = nil
intrinsicSizeContentView = nil
}
/// Tests that the view hierarchy has the expected topology.
func testViewHierarchy() {
// The content view's superview should be the scroll view.
XCTAssertEqual(intrinsicSizeContentView.superview, scrollView)
// The scroll view's superview should be the view controller's root view.
XCTAssertEqual(scrollView.superview, rootView)
}
/// Tests that the content view and the scroll view have the expected size.
func testDefaultLayout() {
let rootViewSafeAreaSize = rootView.bounds.inset(by: rootView.safeAreaInsets).size
// The content view's frame should match the size of the root view's safe area.
XCTAssertEqual(intrinsicSizeContentView.frame.size, rootViewSafeAreaSize)
// The scroll view's content size should match that of the root view's safe area.
XCTAssertEqual(scrollView.contentSize, rootViewSafeAreaSize)
}
/// Tests that the size of the content view is the expected size for the case of a
/// view that's taller than root view's safe area.
func testExpandedLayout() {
intrinsicSizeContentView.height = 2000
scrollView.setNeedsLayout()
scrollView.layoutIfNeeded()
// The content view should stretch to larger than the root view's safe area.
XCTAssertEqual(intrinsicSizeContentView.frame.size.height, intrinsicSizeContentView.height)
// The scroll view's content size height should match that of the content view.
XCTAssertEqual(scrollView.contentSize.height, intrinsicSizeContentView.height)
}
}
+102
View File
@@ -0,0 +1,102 @@
//
// KeyboardTests.swift
// ScrollingContentViewControllerTests
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import XCTest
@testable import ScrollingContentViewController
/// Test case of presenting the keyboard.
class KeyboardTests: XCTestCase {
var window: UIWindow!
var scrollingContentViewManager: ScrollingContentViewManager!
var hostViewController: UIViewController!
var contentView: ContentView!
var scrollView: UIScrollView!
var rootView: UIView!
override func setUp() {
window = UIWindow(frame: UIScreen.main.bounds)
window.isHidden = false
hostViewController = UIViewController()
scrollingContentViewManager = ScrollingContentViewManager(hostViewController: hostViewController)
contentView = ContentView()
scrollingContentViewManager.contentView = contentView
scrollingContentViewManager.shouldResizeContentViewForKeyboard = true
hostViewController.beginAppearanceTransition(true, animated: false)
window.rootViewController = hostViewController
hostViewController.view.layoutIfNeeded()
hostViewController.endAppearanceTransition()
scrollView = scrollingContentViewManager.scrollView
rootView = hostViewController.view
}
override func tearDown() {
hostViewController.beginAppearanceTransition(false, animated: false)
window.rootViewController = nil
hostViewController.endAppearanceTransition()
window.isHidden = true
window = nil
hostViewController = nil
scrollView = nil
rootView = nil
contentView = nil
}
/// Tests that the view hierarchy has the expected topology.
func testViewHierarchy() {
// The content view's superview should be the scroll view.
XCTAssertEqual(contentView.superview, scrollView)
// The scroll view's superview should be the view controller's root view.
XCTAssertEqual(scrollView.superview, rootView)
}
/// Tests that the content view and the scroll view have the expected size.
func testDefaultLayout() {
let rootViewSafeAreaSize = rootView.bounds.inset(by: rootView.safeAreaInsets).size
// The content view's frame should match the size of the root view's safe area.
XCTAssertEqual(contentView.frame.size, rootViewSafeAreaSize)
// The scroll view's content size should match that of the root view's safe area.
XCTAssertEqual(scrollView.contentSize, rootViewSafeAreaSize)
}
/// Tests that presenting the keyboard affects the size of the content view.
func testPresentedKeyboard() {
let keyboardHeight: CGFloat = 258
let keyboardFrame = CGRect(x: 0, y: window.bounds.height - keyboardHeight, width: window.bounds.width, height: keyboardHeight)
let initialSafeAreaSize = rootView.bounds.inset(by: rootView.safeAreaInsets).size
let initialBottomInset = scrollView.adjustedContentInset.bottom
// A test keyboard frame must be injected here because keyboard notifications will
// not be generated when a first responder is assigned within a test.
let keyboardFrameEvent = KeyboardFrameEvent(keyboardFrame: keyboardFrame, duration: 0.35)
scrollingContentViewManager.keyboardObserver?.testKeyboardFrameEvent(keyboardFrameEvent)
let finalSafeAreaSize = CGSize(width: initialSafeAreaSize.width, height: initialSafeAreaSize.height - (keyboardHeight - initialBottomInset))
XCTAssertEqual(contentView.frame.size, finalSafeAreaSize)
}
}
+96
View File
@@ -0,0 +1,96 @@
//
// ManagerTests.swift
// ScrollingContentViewControllerTests
//
// Created by Drew Olbrich on 1/19/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import XCTest
import ScrollingContentViewController
/// Test case of using `ScrollingContentViewManager` in code.
class ManagerTests: XCTestCase {
var window: UIWindow!
var scrollingContentViewManager: ScrollingContentViewManager!
var hostViewController: UIViewController!
var contentView: ContentView!
var scrollView: UIScrollView!
var rootView: UIView!
override func setUp() {
window = UIWindow(frame: UIScreen.main.bounds)
window.isHidden = false
hostViewController = UIViewController()
scrollingContentViewManager = ScrollingContentViewManager(hostViewController: hostViewController)
contentView = ContentView()
scrollingContentViewManager.contentView = contentView
hostViewController.beginAppearanceTransition(true, animated: false)
window.rootViewController = hostViewController
hostViewController.view.layoutIfNeeded()
hostViewController.endAppearanceTransition()
scrollView = scrollingContentViewManager.scrollView
rootView = hostViewController.view
}
override func tearDown() {
hostViewController.beginAppearanceTransition(false, animated: false)
window.rootViewController = nil
hostViewController.endAppearanceTransition()
window.isHidden = true
window = nil
hostViewController = nil
scrollView = nil
rootView = nil
contentView = nil
}
/// Tests that the view hierarchy has the expected topology.
func testViewHierarchy() {
// The content view's superview should be the scroll view.
XCTAssertEqual(contentView.superview, scrollView)
// The scroll view's superview should be the view controller's root view.
XCTAssertEqual(scrollView.superview, rootView)
}
/// Tests that the content view and the scroll view have the expected size.
func testDefaultLayout() {
let rootViewSafeAreaSize = rootView.bounds.inset(by: rootView.safeAreaInsets).size
// The content view's frame should match the size of the root view's safe area.
XCTAssertEqual(contentView.frame.size, rootViewSafeAreaSize)
// The scroll view's content size should match that of the root view's safe area.
XCTAssertEqual(scrollView.contentSize, rootViewSafeAreaSize)
}
/// Tests that the size of the content view is the expected size for the case of a
/// view that's taller than root view's safe area.
func testExpandedLayout() {
contentView.heightConstraint.constant = 2000
scrollView.setNeedsLayout()
scrollView.layoutIfNeeded()
// The content view should stretch to larger than the root view's safe area.
XCTAssertEqual(contentView.frame.size.height, contentView.heightConstraint.constant)
// The scroll view's content size height should match that of the content view.
XCTAssertEqual(scrollView.contentSize.height, contentView.heightConstraint.constant)
}
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Nja-mV-BFd">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Scrolling Content View Controller-->
<scene sceneID="gk6-xS-NPr">
<objects>
<viewController id="Nja-mV-BFd" customClass="ScrollingContentViewController" customModule="ScrollingContentViewController" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="KEj-T6-ART" customClass="ContentView" customModule="ScrollingContentViewControllerTests" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<viewLayoutGuide key="safeArea" id="rt0-RW-3mE"/>
</view>
<connections>
<outlet property="contentView" destination="KEj-T6-ART" id="BkO-d2-W2b"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="JXY-LH-IUo" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="9" y="88"/>
</scene>
</scenes>
</document>
+99
View File
@@ -0,0 +1,99 @@
//
// StoryboardTests.swift
// ScrollingContentViewControllerTests
//
// Created by Drew Olbrich on 1/6/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import XCTest
import ScrollingContentViewController
/// Test case of using `ScrollingContentViewController` with storyboards.
class StoryboardTests: XCTestCase {
var window: UIWindow!
var scrollingContentViewController: ScrollingContentViewController!
var contentView: ContentView!
var scrollView: UIScrollView!
var rootView: UIView!
override func setUp() {
let bundle = Bundle(for: StoryboardTests.self)
let storyboard = UIStoryboard(name: "StoryboardTests", bundle: bundle)
window = UIWindow(frame: UIScreen.main.bounds)
window.isHidden = false
scrollingContentViewController = storyboard.instantiateInitialViewController() as? ScrollingContentViewController
XCTAssertNotNil(scrollingContentViewController)
scrollingContentViewController.beginAppearanceTransition(true, animated: false)
window.rootViewController = scrollingContentViewController
scrollingContentViewController.view.layoutIfNeeded()
scrollingContentViewController.endAppearanceTransition()
guard let contentView = scrollingContentViewController.contentView as? ContentView else {
XCTFail("Content view is not of expected type ContentView")
return
}
self.contentView = contentView
scrollView = scrollingContentViewController.scrollView
rootView = scrollingContentViewController.view
}
override func tearDown() {
scrollingContentViewController.beginAppearanceTransition(false, animated: false)
window.rootViewController = nil
scrollingContentViewController.endAppearanceTransition()
window.isHidden = true
window = nil
scrollingContentViewController = nil
scrollView = nil
rootView = nil
contentView = nil
}
/// Tests that the view hierarchy has the expected topology.
func testViewHierarchy() {
// The content view's superview should be the scroll view.
XCTAssertEqual(contentView.superview, scrollView)
// The scroll view's superview should be the view controller's root view.
XCTAssertEqual(scrollView.superview, rootView)
}
/// Tests that the content view and the scroll view have the expected size.
func testDefaultLayout() {
let rootViewSafeAreaSize = rootView.bounds.inset(by: rootView.safeAreaInsets).size
// The content view's frame should match the size of the root view's safe area.
XCTAssertEqual(contentView.frame.size, rootViewSafeAreaSize)
// The scroll view's content size should match that of the root view's safe area.
XCTAssertEqual(scrollView.contentSize, rootViewSafeAreaSize)
}
/// Tests that the size of the content view is the expected size for the case of a
/// view that's taller than root view's safe area.
func testExpandedLayout() {
contentView.heightConstraint.constant = 2000
scrollView.setNeedsLayout()
scrollView.layoutIfNeeded()
// The content view should stretch to larger than the root view's safe area.
XCTAssertEqual(contentView.frame.size.height, contentView.heightConstraint.constant)
// The scroll view's content size height should match that of the content view.
XCTAssertEqual(scrollView.contentSize.height, contentView.heightConstraint.constant)
}
}