Skip to content

Instantly share code, notes, and snippets.

@MosheBerman
Created January 20, 2016 17:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MosheBerman/1a990d15863737047968 to your computer and use it in GitHub Desktop.
Save MosheBerman/1a990d15863737047968 to your computer and use it in GitHub Desktop.
A textview that can interpolating text with input ranges.
//
// AgreementInput.swift
// Intake
//
// Created by Moshe Berman on 1/14/16.
// Copyright © 2016 Moshe Berman. All rights reserved.
//
import Foundation
func == (left : AgreementInput, right : AgreementInput) -> Bool {
return left.identifier.isEqual(right.identifier)
}
func != (left : AgreementInput, right : AgreementInput) -> Bool {
return !(left == right)
}
class AgreementInput : CustomDebugStringConvertible, Equatable
{
var identifier : NSUUID
var range : NSRange
let field : FormField
var debugDescription : String {
get {
return "(\(identifier.UUIDString)) : \(range), \(field)"
}
}
init(withRange range: NSRange, andField field: FormField) {
self.range = range
self.field = field
self.identifier = NSUUID()
}
}
//
// AgreementView.swift
// Intake
//
// Created by Moshe Berman on 12/11/15.
// Copyright © 2015 Moshe Berman. All rights reserved.
//
import UIKit
class AgreementView: UITextView, UITextViewDelegate {
// MARK: - Input
override var inputAccessoryView : UIView? {
get {
let accessory = InputAccessoryView(frame: CGRectZero)
accessory.agreementView = self
return accessory
}
set (view) {
}
}
// MARK: - Placeholders
var placeholderToken = "__x__"
var inputs : Array<AgreementInput> = [] // Initial positioning of the delimiters
// MARK: - Editing State
var stringLengthBeforeChange : Int = 0
var lastInput : AgreementInput? = nil
var isEditing : Bool = false
// MARK: - Form Submission
var submission : FormSubmission?
var formfields : [FormField] = []
var form : Form? {
didSet {
if let agreement = self.form, let text = agreement.agreementText {
self.submission = FormSubmission(form: agreement)
self.formfields = agreement.allFields()
// Generate input objects first.
self.inputs = self.inputsFromText(text: text)
let strippedText : String = stringByRemovingTokensFromString(string: text, token: self.placeholderToken)
let textWithPlaceholders : String = self.stringByInsertingPlaceholdersIntoText(text: strippedText)
self.text = textWithPlaceholders
self.applyAttributes()
}
}
}
// MARK: - Initializers
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
self.commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.commonInit()
}
/*
Perform initialization common to all initialization paths.
*/
private func commonInit() {
self.delegate = self
self.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptorWithTextStyle(UIFontTextStyleBody), size: 0)
self.subscribeToFontChangeNotifications()
self.subscribeToKeyboardNotifications()
}
// MARK: - UITextViewDelegate
func textViewShouldBeginEditing(textView: UITextView) -> Bool {
return self.currentInput() != nil
}
func textView(textView: UITextView, shouldChangeTextInRange range: NSRange, replacementText text: String) -> Bool {
var shouldEdit = true
self.isEditing = true
// Corner case
let isDelete = (range.length > 0 && text.isEmpty)
guard let currentInput : AgreementInput = self.currentInput() else {
return shouldEdit // TODO: Consider if this should be changed to 'false'
}
let currentRange = currentInput.range
self.stringLengthBeforeChange = textView.text.characters.count
/**
There are two cases where we care about deletes:
1. If the user has deleted all of the text.
2. If the user tries to delete before the start of the current input range.
*/
if isDelete
{
// Case 1, deny editing.
if range.location == currentRange.location - 1
{
shouldEdit = false
}
// Case 2: We want to replace the last character with the plaeholder text.
else if range.location == currentRange.location
{
// Get the placeholder for the current input
let placeholder = self.placeholderTextForInput(currentInput)
// Perform the replacement.
self.textStorage.replaceCharactersInRange(currentRange, withAttributedString: NSAttributedString(string: placeholder))
// Move the selection to the start of the current input range.
self.selectedRange = NSMakeRange(currentInput.range.location, 0)
// Calculate the delta and then update.
let delta = deltaValueForTextChangeInTextView(textView: textView)
self.updateDeltasAndFormData(delta, input: currentInput)
shouldEdit = false
}
}
else
{
/**
When "forward-editing" we care about:
1. Newlines, which we don't want to allow.
2. The first insertion when there's placeholder - we need to replace the placeholder and correctly position the carat.
3. Every other case, where we just allow the insertion.
*/
let rangeOfNewline : NSRange = (text as NSString).rangeOfCharacterFromSet(NSCharacterSet.newlineCharacterSet())
let containsNewline = rangeOfNewline.location != NSNotFound
// Case 1. Deny editing.
if containsNewline
{
shouldEdit = false
}
// Case 2. We want to replace the placeholder with the text.
else if self.currentInputContainsPlaceholderText()
{
// Replace the text
self.textStorage.replaceCharactersInRange(currentRange, withAttributedString: NSAttributedString(string: text))
// Calculate a new range which will begin after the characters
// we just inserted, so that we position the carat correctly.
let newRange = NSMakeRange(currentRange.location+text.characters.count, 0)
// Now, update the ranges and form values
let delta = deltaValueForTextChangeInTextView(textView: textView)
self.updateDeltasAndFormData(delta, input: currentInput)
// Position the carat correctly.
self.selectedRange = newRange
shouldEdit = false
}
// Case 3.
else
{
shouldEdit = true
}
}
// If we are done editing, we need to change global state
// Else, change it in textViewDidChange.
self.isEditing = shouldEdit
return shouldEdit
}
func textViewDidEndEditing(textView: UITextView) {
self.lastInput = nil
}
func textViewDidChangeSelection(textView: UITextView) {
/**
We don't fire if we just made a manual input change.
This allows to not have to worry about being out of bounds after
character insertions before we adjust the input ranges.
This is handled by the isEditing flag.
---
We want to distinguish between two kinds of selections:
1. Carat
2. Selection
*/
let isCarat = textView.selectedRange.length == 0
/**
When the selection changes **to a valid field**, there are three cases:
1. We could be currently editing nothing.
2. We could be currently editing the field containing the new selection.
3. We could be currently editing a different field from the one containing the new selection.
We need to update state based on this, to handle editing just beyond the bound of our input range.
*/
// The new range is inside of a field.
if let currentInput = self.currentInput()
{
// Case 1: New selection
if self.lastInput == nil
{
self.lastInput = currentInput
}
// Case 2: Selected the current field
else if currentInput == self.lastInput
{
// No need to change.
}
// Case 3: Selected a different field
else
{
self.lastInput = currentInput
let caretRect = self.caretRectForPosition(self.selectedTextRange!.start)
self.scrollRectToVisible(caretRect, animated: true)
}
// If we're inside of a field, and it has placeholder text, jump to start.
if self.currentInputContainsPlaceholderText()
{
self.selectedRange = NSMakeRange(currentInput.range.location, 0)
}
else if !isCarat
{
/**
Three cases for selection of multiple character:
1. The selection is contained entirely in the active range. Don't need to do anything.
2. The range extends beyond the front (location) of the current range.
3. The range extends beyond the back (location + length) of the current range.
In cases 2 & 3, we want to clamp the selection. For case 1, do nothing.
*/
// Case 2
if currentInput.range.containsTailOfRange(range: self.selectedRange) {
print("Contains the end of a selection, clamping.")
let differenceBetweenTheTwoLocations = currentInput.range.overlapLengthWithRange(range: self.selectedRange)
print("Difference: \(differenceBetweenTheTwoLocations)")
let newRange = NSMakeRange(currentInput.range.location + differenceBetweenTheTwoLocations, self.selectedRange.length - differenceBetweenTheTwoLocations)
self.selectedRange = newRange
}
// Case 3
else if currentInput.range.containsHeadOfRange(range: self.selectedRange)
{
print("Contains the start of a selection, clamping.")
let differenceBetweenTheTwoLocations = abs(currentInput.range.location - self.selectedRange.location)
let newLength = currentInput.range.length - differenceBetweenTheTwoLocations
print("New Length: \(newLength) Difference: \(differenceBetweenTheTwoLocations)")
let newRange = NSMakeRange(self.selectedRange.location, newLength)
self.selectedRange = newRange
}
}
}
// Selection is outside a field, end editing.
// Only fire if we aren't mid-editing transaction
else if !isEditing
{
// print("Tapped outside of a field. Force end editing.")
textView.endEditing(true)
}
}
func textViewDidChange(textView: UITextView) {
self.isEditing = false
guard let lastInput = self.lastInput else {
return
}
// Calculate the change in text length
let delta = self.deltaValueForTextChangeInTextView(textView: textView)
self.updateDeltasAndFormData(delta, input: lastInput)
}
// MARK: - Updating State
/**
Update the form submission and the deltas.
*/
func updateDeltasAndFormData(delta:Int, input lastInput: AgreementInput)
{
self.updateRangesWithDelta(delta, beginningWithInput: lastInput)
self.updateSubmissionsFromRelevantRanges()
self.applyAttributes() // Must occur after updating submissions because this relies on placeholder text.
}
// MARK: - Displaying a Form
/**
Collect all of the placeholder indices into an instance variable. The String that we iterated, without any delimiters in it.
- parameter text : A string containing placeholders which we'll use to create our form.
- returns: A string without the placeholders.
*/
private func inputsFromText(let text text:String) -> [AgreementInput] {
// Reset the indices
var inputs : [AgreementInput] = []
var castedText : NSString = text
var range = castedText.rangeOfString(self.placeholderToken)
var cursor = 0
while range.location != NSNotFound && cursor < self.formfields.count {
let input = AgreementInput(withRange: range, andField: self.formfields[cursor])
inputs.append(input)
castedText = castedText.stringByReplacingOccurrencesOfString(self.placeholderToken, withString: "", options: [], range: range)
range = castedText.rangeOfString(self.placeholderToken)
cursor = cursor + 1
}
return inputs
}
/**
Gets a string that is the result of the input string, without any placeholder tokens.
- returns : The original string with all occurrences of the token removed.
*/
private func stringByRemovingTokensFromString(string string : String, token: String) -> String {
return string.stringByReplacingOccurrencesOfString(token, withString: "")
}
/**
Insert the initial placeholders into the form.
- parameter text : A String to insert placeholders into. We'll use the instance's delimiterIndices for information on where to place them.
- returns : A String containing the original text and the insertions.
*/
private func stringByInsertingPlaceholdersIntoText(text inputString: String) -> String {
var offset = 0
let text : NSMutableString = NSMutableString(string: inputString)
for input in self.inputs {
let range = input.range
// Get the string to display
let displayText = self.textToDisplayForInput(input: input)
text.insertString(displayText, atIndex: range.location + offset)
// Update the target range in the our array of ranges
let count = displayText.characters.count
let newRange = NSMakeRange(range.location+offset, count)
input.range = newRange
// Offset the subsequent ranges based on the length of the string we just inserted
offset += newRange.length
// print("Inserted placeholder '\(displayText)' as placeholder into range \(newRange) with count of \(count)")
}
return text as String
}
/**
Applies the appropriate style to our text.
*/
func applyAttributes()
{
self.applyAttributes(highlightInvalidFields: false)
}
func applyAttributes(highlightInvalidFields highlightInvalidFields: Bool)
{
var attributes : [String : AnyObject] = [:]
attributes = [
NSFontAttributeName : UIFont(descriptor: UIFontDescriptor.preferredFontDescriptorWithTextStyle(UIFontTextStyleBody), size: 0)
]
let entireRange = NSRange(0..<self.text.characters.count)
self.textStorage.setAttributes(attributes, range: entireRange)
// print("Entire range: \(entireRange)")
for input in self.inputs {
let range = input.range
if let attributes = self.attributesForInput(input, highlightInvalidInput: highlightInvalidFields)
{
self.textStorage.addAttributes(attributes, range: range)
// print("Adding attributes for range \(input)")
}
else
{
// print("No attributes for range \(input).")
}
}
}
/**
Iterate the submissions ranges and update the values.
If range matching the field contains the placeholder,
remove the value from the submission object. Otherwise,
assume the value is useful and collect it.
*/
private func updateSubmissionsFromRelevantRanges() {
for input in self.inputs {
let field = input.field
let range = input.range
let entireText : NSString = (self.textStorage.string as NSString)
let rangeOfEntireText = NSMakeRange(0, entireText.length)
if rangeOfEntireText.contains(range: range)
{
let text : NSString = (self.textStorage.string as NSString).substringWithRange(range)
if text.isEqualToString(self.placeholderTextForInput(input)) {
self.submission?.removeSubmissionValueForField(field)
}
else
{
self.submission?.setSubmissionValueForField(text, field: field)
}
}
else
{
print("Found a range that doesn't overlap correctly: \(input)")
}
}
if let submission = self.submission
{
print("\(submission)")
}
else
{
print("No submission.")
}
}
// MARK: - Input Values and Placeholders
/**
This can be the placeholder, the submission value, or the field description.
- parameter input : An AgreementInput object from our form.
*/
private func textToDisplayForInput(input input : AgreementInput) -> String {
var text = self.placeholderTextForInput(input)
// Now try to overwrite the default with a submission value.
if let submission = self.submission, let object : String = submission.submissionValueForField(field: input.field) as? String {
text = object
}
return text
}
/**
Get the placeholder text for a field. If the field has no name and no description,
a localized version of the word "Field" will be returned.
- returns : A string to display inside of the form as a placeholder for the given field.
*/
private func placeholderTextForInput(input : AgreementInput) -> String {
let field = input.field
/**
* Try in order:
* - Display name
* - Description
* - Generic "Field"
*/
return ((field.displayName ?? field.fieldDescription ?? NSLocalizedString("Field", comment: "")) as String)
}
// MARK: - Display Attributes
/**
Calculates and returns the attributes for a given range by looking up the field and calling displayAttributesForField()
- returns : If the range corresponds to a field, returns a dictionary with text display attributes. Otherwise, nil.
*/
private func attributesForInput(input: AgreementInput, highlightInvalidInput : Bool)-> [String : AnyObject]? {
let description = self.textToDisplayForInput(input: input)
var color = UIColor.blackColor()
if description == input.field.fieldDescription || description == input.field.displayName
{
color = UIColor.lightGrayColor()
}
if highlightInvalidInput == true
{
if let submission = self.submission
{
if !submission.fieldIsValid(input.field)
{
color = UIColor(red: 0.8, green: 0, blue: 0, alpha: 1.0)
}
else if input.field.required == false && submission.submissionValueForField(field: input.field) == nil {
color = UIColor(red: 0.8, green: 0.8, blue: 0.0, alpha: 1.0)
}
else
{
print("Valid input")
}
}
}
let attributes : [String : AnyObject] = [
NSForegroundColorAttributeName : color,
NSUnderlineColorAttributeName : UIColor.blackColor(),
NSUnderlineStyleAttributeName : NSUnderlineStyle.StyleDouble.rawValue
]
return attributes
}
// MARK: - Ranges
/**
Returns a range representing an editable piece of the text, assuming
the target range overlaps the supplied range.
- parameter range : A range to check against our collection of placeholder delimiter ranges.
- returns : An NSRange containing the first or last index in the range, if it exists. Otherwise returns nil.
*/
private func inputContaining(range testRange : NSRange) -> NSRange? {
var rangeToReturn : NSRange?
for input in self.inputs {
let editableRange = input.range
if editableRange.contains(range: testRange) {
rangeToReturn = editableRange
break
}
}
return rangeToReturn
}
/**
Gets the currently edited input.
- returns : An input that matches the range that we're editing
*/
private func currentInput() -> AgreementInput? {
var currentInput : AgreementInput? = nil
for input in self.inputs {
if input.range.contains(range: self.selectedRange) {
currentInput = input
break
}
}
return currentInput
}
/**
Determines if the current range contains placeholder text.
- returns : true if the current range's text is a placeholder.
*/
func currentInputContainsPlaceholderText() -> Bool {
guard let input = self.currentInput() else {
return false
}
return self.textToDisplayForInput(input: input) == self.placeholderTextForInput(input)
}
/**
Moves all editable ranges that follow a given range by the supplied delta.
- parameter delta : The offset to adjust the ranges
- parameter modifiedRange : The range to begin with. All ranges including and after the range at the supplied index are modified.
*/
private func updateRangesWithDelta(delta: Int, beginningWithInput input: AgreementInput) {
let startIndex : Int = (self.inputs as NSArray).indexOfObject(input)
var index = startIndex
if index == NSNotFound {
print("Can't find start input...")
}
// print("Adjusting ranges from index \(index)\nDelta: \(delta)")
while index < self.inputs.count {
let input : AgreementInput = self.inputs[index]
let originalRange : NSRange = input.range
var newRange : NSRange
// If we are modifying the first range
// Update the length.
if index == startIndex {
newRange = NSRange(location: originalRange.location, length: originalRange.length + delta)
}
else // update the location
{
newRange = NSRange(location: originalRange.location + delta, length: originalRange.length)
}
// print("Replacing \(self.inputs[index].range) with \(newRange).")
self.inputs[index].range = newRange
index = index + 1
}
}
// MARK: - Text Manipulation
/**
Calculates how many characters the ranges should be offset by the text change.
- parameter textView : The UITextView who's delegate is calling
- returns A signed character offset which is the number of characters deleted or replaced.
*/
func deltaValueForTextChangeInTextView(textView textView:UITextView) -> Int {
let delta : Int = textView.text.characters.count - self.stringLengthBeforeChange
// print("\nDelta: \(delta)");
return delta
}
/**
Checks if newly inserted text is the second whitespace in a row, which triggers conversion of both spaces to a period/full stop.
- parameter newText : The text being inserted
- parameter text : The original text
- parameter range: The range describing where the modification is occurring
- returns : true if inserting this space would cause two spaces in a row to be inserted, otherwise false.
*/
func isNewlyInsertedText(text newText: String, secondConsecutiveSpaceWhenAddedToText text:String, atRange range: NSRange) -> Bool {
var isSecondSpace : Bool = false
if newText.characters.count > 0 {
let whitespaceCharacterSet : NSCharacterSet = NSCharacterSet.whitespaceCharacterSet()
let previousCharacter = (text as NSString).characterAtIndex(range.location-1)
let isNewTextSingleCharacter = newText.characters.count == 1 // Verify that we are checking one character (edge case: paste text starting with space)
let isNewCharacterWhitespace = whitespaceCharacterSet.characterIsMember((newText as NSString).characterAtIndex(0)) // Is the new
let isPreviousCharacterWhitespace = whitespaceCharacterSet.characterIsMember(previousCharacter)
if isNewTextSingleCharacter && isPreviousCharacterWhitespace && isNewCharacterWhitespace {
isSecondSpace = true
}
}
return isSecondSpace
}
// MARK: - Subscribing to Notifications
/**
Subscribe to content size category changes.
*/
private func subscribeToFontChangeNotifications() {
NSNotificationCenter.defaultCenter().addObserver(self, selector: "contentCategorySizeDidChange:", name: UIContentSizeCategoryDidChangeNotification, object: nil)
}
/**
Subscribe to keyboard change notifications.
*/
private func subscribeToKeyboardNotifications() {
NSNotificationCenter.defaultCenter().addObserver(self, selector: "keyboardChanged:", name: UIKeyboardDidShowNotification, object: nil)
NSNotificationCenter.defaultCenter().addObserver(self, selector: "keyboardChanged:", name: UIKeyboardDidHideNotification, object: nil)
}
// MARK: - Handling Notifications
/**
Handle keyboard change notifications.
- parameter notification : An NSNotification delivered by NSNotificationCenter
*/
func keyboardChanged(notification : NSNotification) {
guard let userInfo : NSDictionary = notification.userInfo else {
print("There's no keyboard notification userinfo. That's a bug.")
return
}
let key : NSString = UIKeyboardFrameEndUserInfoKey
let boundsValue : NSValue = userInfo[key] as! NSValue
let bounds = boundsValue.CGRectValue()
let intersection = CGRectIntersection(bounds, self.frame)
let oldTop = self.contentInset.top
self.contentInset = UIEdgeInsetsMake(oldTop, 0, intersection.size.height, 0)
self.scrollIndicatorInsets = self.contentInset
}
/**
Handle Content Category Size change notifications.
- parameter notification : An NSNotification delivered by NSNotificationCenter
*/
func contentCategorySizeDidChange(notification : NSNotification) {
self.applyAttributes()
}
// MARK: - Modifying Selection
/**
If there's a current input, find the next one.
- returns : An AgreementInput if there's a "next" one. If not, returns nil.
*/
func nextInput() -> AgreementInput? {
var input : AgreementInput? = nil
if let current = self.currentInput(), let index = self.inputs.indexOf(current) {
let newIndex = index.successor()
if newIndex < self.inputs.count && newIndex >= 0
{
input = self.inputs[newIndex]
}
}
return input
}
/**
If there's a current input, find the next one.
- returns : An AgreementInput if there's a "next" one. If not, returns nil.
*/
func previousInput() -> AgreementInput? {
var input : AgreementInput? = nil
if let current = self.currentInput(), let index = self.inputs.indexOf(current) {
let newIndex = index.predecessor()
if newIndex < self.inputs.count && newIndex >= 0
{
input = self.inputs[newIndex]
}
}
return input
}
func hasNextInput() -> Bool {
var hasNext : Bool = false
if let _ = nextInput() {
hasNext = true
}
return hasNext
}
func hasPreviousInput() -> Bool {
var hasPrev : Bool = false
if let _ = self.previousInput() {
hasPrev = true
}
return hasPrev
}
// MARK: - Activating A Different Input
func activateNext() {
if let next = self.nextInput()
{
self.selectedRange = next.range
}
}
func activatePrevious() {
if let previous = self.previousInput()
{
self.selectedRange = previous.range
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment