Search code examples
swiftcocoansattributedstringnstextfieldnsnumberformatter

How to use a NSNumberFormatter with an AttributedString?


I have been unable to find anything that works on the subject of using an attributed text in a NSTextField with a NumberFormatter. What I want to accomplish is very simple. I would like to use a NumberFormatter on an editable NSTextField with attributed text and keep the text attributed.

Currently, I have subclassed NSTextFieldCell and implemented it as so:

class AdjustTextFieldCell: NSTextFieldCell {

  required init(coder: NSCoder) {
    super.init(coder: coder)
    let attributes = makeAttributes()
    allowsEditingTextAttributes = true
    attributedStringValue = AttributedString(string: stringValue, attributes: attributes)
    //formatter = TwoDigitFormatter()
  }

  func makeAttributes() -> [String: AnyObject] {
    let style = NSMutableParagraphStyle()
    style.minimumLineHeight = 100
    style.maximumLineHeight = 100
    style.paragraphSpacingBefore = 0
    style.paragraphSpacing = 0
    style.alignment = .center
    style.lineHeightMultiple = 1.0
    style.lineBreakMode = .byTruncatingTail
    let droidSansMono = NSFont(name: "DroidSansMono", size: 70)!
    return [NSParagraphStyleAttributeName: style, NSFontAttributeName: droidSansMono, NSBaselineOffsetAttributeName: -60]
  }


}

This implementation adjusts the text in the NSTextField instance to have the shown attributes. When I uncomment the line that sets the formatter property the NSTextField loses its attributes. My NumberFormatter is as follows:

class TwoDigitFormatter: NumberFormatter {

  override init() {
    super.init()
    let customAttribs = makeAttributes()
    textAttributesForNegativeValues = customAttribs.attribs
    textAttributesForPositiveValues = customAttribs.attribs
    textAttributesForZero = customAttribs.attribs
    textAttributesForNil = customAttribs.attribs
  }

  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
  }

  let maxLength = 2
  let wrongCharacterSet = CharacterSet(charactersIn: "0123456789").inverted

  override func isPartialStringValid(_ partialString: String, newEditingString newString: AutoreleasingUnsafeMutablePointer<NSString?>?, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
    if partialString.characters.count > maxLength {
      return false
    }

    if partialString.rangeOfCharacter(from: wrongCharacterSet) != nil {
      return false
    }

    return true
  }

  override func attributedString(for obj: AnyObject, withDefaultAttributes attrs: [String : AnyObject]? = [:]) -> AttributedString? {
    let stringVal = string(for: obj)

    guard let string = stringVal else { return nil }
    let customAttribs = makeAttributes()
    var attributes = attrs
    attributes?[NSFontAttributeName] = customAttribs.font
    attributes?[NSParagraphStyleAttributeName] = customAttribs.style
    attributes?[NSBaselineOffsetAttributeName] = customAttribs.baselineOffset

    return AttributedString(string: string, attributes: attributes)

  }

  func makeAttributes() -> (font: NSFont, style: NSMutableParagraphStyle, baselineOffset: CGFloat, attribs: [String: AnyObject]) {
    let style = NSMutableParagraphStyle()
    style.minimumLineHeight = 100
    style.maximumLineHeight = 100
    style.paragraphSpacingBefore = 0
    style.paragraphSpacing = 0
    style.alignment = .center
    style.lineHeightMultiple = 1.0
    style.lineBreakMode = .byTruncatingTail
    let droidSansMono = NSFont(name: "DroidSansMono", size: 70)!
    return (droidSansMono, style, -60, [NSParagraphStyleAttributeName: style, NSFontAttributeName: droidSansMono, NSBaselineOffsetAttributeName: -60])
  }
}

As you can see from the code directly above I have tried:

  1. Setting the textAttributesFor... properties.
  2. Overriding attributedString(for obj: AnyObject, withDefaultAttributes attrs: [String : AnyObject]? = [:])

I have tried both these solution separately from each other and together, of which none of the attempts worked.

TLDR:
Is it possible to use attributed text and a NumberFormatter at the same time? If so, how? If not, how can I limit a NSTextField with attributed text to digits only and two characters without using a NumberFormatter?


Solution

  • For anyone in the future who wants to achieve the behavior I was able to do it by subclassing NSTextView and essentially faking a NSTextField like so:

    class FakeTextField: NSTextView {
    
      required init?(coder: NSCoder) {
        super.init(coder: coder)
    
        delegate = self
    
        let droidSansMono = NSFont(name: "DroidSansMono", size: 70)!
        configure(lineHeight: frame.size.height, alignment: .center, font: droidSansMono)
      }
    
      func configure(lineHeight: CGFloat, alignment: NSTextAlignment, font: NSFont) {
        //Other Configuration
    
        //Define and set typing attributes
        let style = NSMutableParagraphStyle()
        style.minimumLineHeight = lineHeight
        style.maximumLineHeight = lineHeight
        style.alignment = alignment
        style.lineHeightMultiple = 1.0
        typingAttributes = [NSParagraphStyleAttributeName: style, NSFontAttributeName: font]
      }
    
    }
    
    extension TextField: NSTextViewDelegate {
      func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool {
        if let oldText = textView.string, let replacement = replacementString {
          let newText = (oldText as NSString).replacingCharacters(in: affectedCharRange, with: replacement)
          let numberOfChars = newText.characters.count
          let wrongCharacterSet = CharacterSet(charactersIn: "0123456789").inverted
          let containsWrongCharacters = newText.rangeOfCharacter(from: wrongCharacterSet) != nil
          return numberOfChars <= 2 && !containsWrongCharacters
        }
        return false
      }
    }
    

    With this class, all you need to do is set the NSTextView in your storyboard to this class and change the attributes and shouldChangeTextIn to suit your needs. In this implementation, the "TextField" has various attributes set and is limited to two characters and only digits.