Rightpoint / BonMot

Beautiful, easy attributed strings in Swift
MIT License
3.54k stars 197 forks source link

Question: init `StringStyle` from `[NSAttributedStringKey: Any?]` - good idea? #326

Closed sstadelman closed 6 years ago

sstadelman commented 6 years ago

Question:

Should it be feasible to init a StringStyle from [NSAttributedStringKey: Any?]? It seems like it would be common-enough scenario, that I wanted to check to see if a) it's already implemented somewhere I'm missing, and b) if not, if there's a good reason why not?

Background:

I've been working with StringStyle in a UI-controls SDK framework for several month now, and it's really great for maintaining the component styles we get from the Design team in one location in the component (rather than scattered in init() and setup() and the xib etc.). We end up with really great abstraction that makes it easy to aggregate various style definitions from the component, and then stylesheets, with implementations like this:

// style definitions, using `extraAttributes`
private enum Styles {
    static var subtitleStyle = StringStyle(
        .font(UIFont.systemFont(ofSize: 15, weight: .regular)),
        .color(UIColor.preferredFioriColor(forStyle: .primary3)),
        .paragraphSpacingBefore(3.0),
        .extraAttributes([.fuiNumberOfLines: 1])
    )    // ...
}

// implementation in a component
override open func defaultAttributes(for property: FUIPropertyRef) -> [NSAttributedStringKey : Any] {
    switch property {
    case .subtitle:
        return Styles.subtitleStyle.attributes
    // ...
    default:
        return super.defaultAttributes(for: property)
    }
}

// Protocol implementation, composing styles from multiple levels of providers
extension FUIAttributesProvider where Self: FUIStyleByTintAttributes {
    /// Resultant set of attributes after computing default/system attributes, tint attributes, and nss attributes
    public func mergedAttributes(for property: FUIPropertyRef) -> [NSAttributedStringKey: Any] {
        let defaultAttributes = self.defaultAttributes(for: property)
        let tintAttributes = self.tintAttributes(for: property, state: self.tintState)
        let styleSheetAttributes = self.styleSheetAttributes(for: property)

        return defaultAttributes
            .merging(tintAttributes, uniquingKeysWith: { (_, new) -> Any in return new })
            .merging(styleSheetAttributes, uniquingKeysWith: { (_, new) -> Any in return new })
    }
    /// Method returning an array of tintAttributes for a given `FUIPropertyRef` and `FUIControlState`
    public func tintAttributes(for property: FUIPropertyRef, state: FUIControlState) -> [NSAttributedStringKey: Any] {
        return tintAttributes[property, default: [:]][state, default: [:]]
    }
}

// Compose the `NSAttributedString` substring for the component
public var subtitleAttributedText: NSAttributedString! {
        guard let subtitleText = self.subtitle.text else {  return NSAttributedString() }

        // grab some local `[NSAttributedStringKey: Any]` from the property itself
        var attributes = self.subtitle.attributes()  

        // do a massive merge of the provider attributes, & overwrite with local attributes
        if let defaultAndTheming = self.attributesProvider?.mergedAttributes(for: .subtitle) {
            attributes.merge(defaultAndTheming, uniquingKeysWith: { (developer, _ ) -> Any in
                return developer
            })
        }

        // return the new styled `NSAttributedString`
       return NSAttributedString(string: subtitleText, attributes: attributes)
}

The trick though, is that to interact with the non-StringStyle providers, I've had to standardize on [NSAttributedStringKey: Any], so I'm getting only the most superficial benefit of the StringStyle. Where I'd really like to get to, is to standardize in the internals on StringStyle, so that I get the benefits of the composition, especially wrt the NSParagraphStyle properties.

To do that, I think I need to implement something like StringStyle(attributes: [NSAttributedStringKey: Any?]). Any concerns?

sstadelman commented 6 years ago

Partial answer: supplyDefaults(for attributes: StyleAttributes?) -> StyleAttributes accomplishes the merging solution, which is very nice.

If anyone else is interested, I did a partial (untested) implementation, that accounts for most of the out-of-box NSAttributedStringKey's.

init(attributes: [NSAttributedStringKey: Any?]) {
    self.init()

    var paragraphStyle: NSParagraphStyle? = nil

    for pair in attributes {
        let key = pair.key, value = pair.value

        switch key {
        case .paragraphStyle:
            paragraphStyle = value as? NSParagraphStyle
        case .font:
            self.font = value as? BONFont
        case .link:
            self.link = value as? URL
        case .backgroundColor:
            self.backgroundColor = value as? BONColor
        case .foregroundColor:
            self.color = value as? BONColor
        case .underlineStyle:
            guard let underlineStyle = value as? NSUnderlineStyle else {
                self.underline?.0 = .styleNone
                break
            }
            if self.underline == nil {
                self.underline = (.styleNone, nil)
            }
            self.underline?.0 = underlineStyle
        case .underlineColor:
            if self.underline == nil {
                self.underline = (.styleNone, nil)
            }
            self.underline?.1 = value as? BONColor
        case .strikethroughStyle:
            guard let strikethroughStyle = value as? NSUnderlineStyle else {
                self.strikethrough?.0 = .styleNone
                break
            }
            if self.strikethrough == nil {
                self.strikethrough = (.styleNone, nil)
            }
            self.strikethrough?.0 = strikethroughStyle
        case .strikethroughColor:
            if self.strikethrough == nil {
                self.strikethrough = (.styleNone, nil)
            }
            self.strikethrough?.1 = value as? BONColor
        case .baselineOffset:
            self.baselineOffset = value as? CGFloat
        case .ligature:
            guard let ligature = value as? NSNumber else {
                self.ligatures = nil
                break
            }
            self.ligatures = Ligatures(rawValue: ligature.intValue)
        case NSAttributedStringKey(UIAccessibilitySpeechAttributePunctuation):
            self.speaksPunctuation = value as? Bool
        case NSAttributedStringKey(UIAccessibilitySpeechAttributeLanguage):
            self.speakingLanguage = value as? String
        case NSAttributedStringKey(UIAccessibilitySpeechAttributePitch):
            self.speakingPitch = value as? Double
        default:
            self.extraAttributes.update(possibleValue: value, forKey: key)
        }

        if #available(iOS 11.0, *) {
            switch key {
            case NSAttributedStringKey(UIAccessibilitySpeechAttributeIPANotation):
                self.speakingPronunciation = value as? String
            case NSAttributedStringKey(UIAccessibilitySpeechAttributeQueueAnnouncement):
                self.shouldQueueSpeechAnnouncement = value as? Bool
            case NSAttributedStringKey(UIAccessibilityTextAttributeHeadingLevel):
                guard let level = value as? NSNumber else {
                    self.headingLevel = nil
                    break
                }
                self.headingLevel = HeadingLevel(rawValue: level.intValue)
            default:
                // Ignore, as these attributes were handled in the unspecialized switch
                break
            }
        } else {
            // Ignore, as these attributes are unsupported
        }
    }

    self.lineSpacing = paragraphStyle?.lineSpacing// : CGFloat?
    self.paragraphSpacingAfter = paragraphStyle?.paragraphSpacing//: CGFloat?
    self.alignment = paragraphStyle?.alignment// : NSTextAlignment?
    self.firstLineHeadIndent = paragraphStyle?.firstLineHeadIndent// : CGFloat?
    self.headIndent = paragraphStyle?.headIndent
    self.tailIndent = paragraphStyle?.tailIndent
    self.lineBreakMode = paragraphStyle?.lineBreakMode
    self.minimumLineHeight = paragraphStyle?.minimumLineHeight
    self.maximumLineHeight = paragraphStyle?.maximumLineHeight
    self.baseWritingDirection = paragraphStyle?.baseWritingDirection
    self.lineHeightMultiple = paragraphStyle?.lineHeightMultiple
    self.paragraphSpacingBefore = paragraphStyle?.paragraphSpacingBefore
    self.hyphenationFactor = paragraphStyle?.hyphenationFactor
}
ZevEisenberg commented 6 years ago

@sstadelman thanks for sharing your implementation! I could definitely see it being useful to initialize a StringStyle from a dictionary of attributed string attributes. I would consider merging a pull request if you wanted to file one. It should be pretty easy to write some unit tests for it, too.