diff --git a/CONFIG.md b/CONFIG.md index fdcf94e..3282131 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -120,6 +120,8 @@ ios: typography: # [optional] Absolute or relative path to swift file where to export UIKit fonts (UIFont extension). fontSwift: "./Source/UIComponents/UIFont+extension.swift" + # [optional] Absolute or relative path to swift file where to generate LabelStyle extensions for each style (LabelStyle extension). + labelStyleSwift: "./Source/UIComponents/LabelStyle+extension.swift" # [optional] Absolute or relative path to swift file where to export SwiftUI fonts (Font extension). swiftUIFontSwift: "./Source/View/Common/Font+extension.swift" # Should FigmaExport generate UILabel for each text style (font)? E.g. HeaderLabel, BodyLabel, CaptionLabel diff --git a/Sources/FigmaExport/Input/Params.swift b/Sources/FigmaExport/Input/Params.swift index e47cbb7..bfe794f 100644 --- a/Sources/FigmaExport/Input/Params.swift +++ b/Sources/FigmaExport/Input/Params.swift @@ -82,6 +82,7 @@ struct Params: Decodable { struct Typography: Decodable { let fontSwift: URL? + let labelStyleSwift: URL? let swiftUIFontSwift: URL? let generateLabels: Bool let labelsDirectory: URL? diff --git a/Sources/FigmaExport/Subcommands/ExportTypography.swift b/Sources/FigmaExport/Subcommands/ExportTypography.swift index 11e8f34..49169bd 100644 --- a/Sources/FigmaExport/Subcommands/ExportTypography.swift +++ b/Sources/FigmaExport/Subcommands/ExportTypography.swift @@ -59,14 +59,28 @@ extension FigmaExportCommand { } } - private func exportXcodeTextStyles(textStyles: [TextStyle], iosParams: Params.iOS, logger: Logger) throws { - let output = XcodeTypographyOutput( + private func createXcodeOutput(from iosParams: Params.iOS) -> XcodeTypographyOutput { + let fontUrls = XcodeTypographyOutput.FontURLs( fontExtensionURL: iosParams.typography?.fontSwift, - swiftUIFontExtensionURL: iosParams.typography?.swiftUIFontSwift, - generateLabels: iosParams.typography?.generateLabels, + swiftUIFontExtensionURL: iosParams.typography?.swiftUIFontSwift + ) + let labelUrls = XcodeTypographyOutput.LabelURLs( labelsDirectory: iosParams.typography?.labelsDirectory, + labelStyleExtensionsURL: iosParams.typography?.labelStyleSwift + ) + let urls = XcodeTypographyOutput.URLs( + fonts: fontUrls, + labels: labelUrls + ) + return XcodeTypographyOutput( + urls: urls, + generateLabels: iosParams.typography?.generateLabels, addObjcAttribute: iosParams.addObjcAttribute ) + } + + private func exportXcodeTextStyles(textStyles: [TextStyle], iosParams: Params.iOS, logger: Logger) throws { + let output = createXcodeOutput(from: iosParams) let exporter = XcodeTypographyExporter(output: output) let files = try exporter.export(textStyles: textStyles) diff --git a/Sources/XcodeExport/Model/XcodeTypographyOutput.swift b/Sources/XcodeExport/Model/XcodeTypographyOutput.swift index fd5b9d3..5859eba 100644 --- a/Sources/XcodeExport/Model/XcodeTypographyOutput.swift +++ b/Sources/XcodeExport/Model/XcodeTypographyOutput.swift @@ -1,24 +1,55 @@ import Foundation public struct XcodeTypographyOutput { - - let fontExtensionURL: URL? - let swiftUIFontExtensionURL: URL? + let urls: URLs let generateLabels: Bool - let labelsDirectory: URL? let addObjcAttribute: Bool + + public struct FontURLs { + let fontExtensionURL: URL? + let swiftUIFontExtensionURL: URL? + public init( + fontExtensionURL: URL? = nil, + swiftUIFontExtensionURL: URL? = nil + ) { + self.swiftUIFontExtensionURL = swiftUIFontExtensionURL + self.fontExtensionURL = fontExtensionURL + } + } + + public struct LabelURLs { + let labelsDirectory: URL? + let labelStyleExtensionsURL: URL? + + public init( + labelsDirectory: URL? = nil, + labelStyleExtensionsURL: URL? = nil + ) { + self.labelsDirectory = labelsDirectory + self.labelStyleExtensionsURL = labelStyleExtensionsURL + } + } + + public struct URLs { + public let fonts: FontURLs + public let labels: LabelURLs + + public init( + fonts: FontURLs, + labels: LabelURLs + ) { + self.fonts = fonts + self.labels = labels + } + } public init( - fontExtensionURL: URL? = nil, - swiftUIFontExtensionURL: URL? = nil, + urls: URLs, generateLabels: Bool? = false, - labelsDirectory: URL? = nil, addObjcAttribute: Bool? = false ) { - self.fontExtensionURL = fontExtensionURL - self.swiftUIFontExtensionURL = swiftUIFontExtensionURL + self.urls = urls self.generateLabels = generateLabels ?? false - self.labelsDirectory = labelsDirectory self.addObjcAttribute = addObjcAttribute ?? false } } diff --git a/Sources/XcodeExport/XcodeTypographyExporter.swift b/Sources/XcodeExport/XcodeTypographyExporter.swift index 74911d9..4c98815 100644 --- a/Sources/XcodeExport/XcodeTypographyExporter.swift +++ b/Sources/XcodeExport/XcodeTypographyExporter.swift @@ -13,7 +13,7 @@ final public class XcodeTypographyExporter { var files: [FileContents] = [] // UIKit UIFont extension - if let fontExtensionURL = output.fontExtensionURL { + if let fontExtensionURL = output.urls.fonts.fontExtensionURL { files.append(contentsOf: try exportFonts( textStyles: textStyles, fontExtensionURL: fontExtensionURL, @@ -22,7 +22,7 @@ final public class XcodeTypographyExporter { } // SwiftUI Font extension - if let swiftUIFontExtensionURL = output.swiftUIFontExtensionURL { + if let swiftUIFontExtensionURL = output.urls.fonts.swiftUIFontExtensionURL { files.append(contentsOf: try exportFonts( textStyles: textStyles, swiftUIFontExtensionURL: swiftUIFontExtensionURL @@ -30,13 +30,22 @@ final public class XcodeTypographyExporter { } // UIKit Labels - if output.generateLabels, let labelsDirectory = output.labelsDirectory { + if output.generateLabels, let labelsDirectory = output.urls.labels.labelsDirectory { // Label.swift // LabelStyle.swift files.append(contentsOf: try exportLabels( textStyles: textStyles, - labelsDirectory: labelsDirectory + labelsDirectory: labelsDirectory, + separateStyles: output.urls.labels.labelStyleExtensionsURL != nil )) + + // LabelStyle extensions + if let labelStyleExtensionsURL = output.urls.labels.labelStyleExtensionsURL { + files.append(contentsOf: try exportLabelStylesExtensions( + textStyles: textStyles, + labelStyleExtensionURL: labelStyleExtensionsURL + )) + } } return files @@ -121,7 +130,7 @@ final public class XcodeTypographyExporter { return [FileContents(destination: destination, data: data)] } - private func exportLabels(textStyles: [TextStyle], labelsDirectory: URL) throws -> [FileContents] { + private func exportLabelStylesExtensions(textStyles: [TextStyle], labelStyleExtensionURL: URL) throws -> [FileContents] { let dict = textStyles.map { style -> [String: Any] in let type: String = style.fontStyle?.textStyleName ?? "" return [ @@ -133,7 +142,31 @@ final public class XcodeTypographyExporter { "tracking": style.letterSpacing.floatingPointFixed, "lineHeight": style.lineHeight ?? 0 ]} - let contents = try TEMPLATE_Label_swift.render(["styles": dict]) + let contents = try labelStyleExtensionSwiftContents.render(["styles": dict]) + + let fileName = labelStyleExtensionURL.lastPathComponent + let directoryURL = labelStyleExtensionURL.deletingLastPathComponent() + let labelStylesSwiftExtension = try makeFileContents(data: contents, directoryURL: directoryURL, fileName: fileName) + + return [labelStylesSwiftExtension] + } + + private func exportLabels(textStyles: [TextStyle], labelsDirectory: URL, separateStyles: Bool) throws -> [FileContents] { + let dict = textStyles.map { style -> [String: Any] in + let type: String = style.fontStyle?.textStyleName ?? "" + return [ + "className": style.name.first!.uppercased() + style.name.dropFirst(), + "varName": style.name, + "size": style.fontSize, + "supportsDynamicType": style.fontStyle != nil, + "type": type, + "tracking": style.letterSpacing.floatingPointFixed, + "lineHeight": style.lineHeight ?? 0 + ]} + let contents = try TEMPLATE_Label_swift.render([ + "styles": dict, + "separateStyles": separateStyles + ]) let labelSwift = try makeFileContents(data: contents, directoryURL: labelsDirectory, fileName: "Label.swift") let labelStyleSwift = try makeFileContents(data: labelStyleSwiftContents, directoryURL: labelsDirectory, fileName: "LabelStyle.swift") @@ -222,6 +255,25 @@ public class Label: UILabel { public final class {{ style.className }}Label: Label { override var style: LabelStyle? { + {% if separateStyles %}.{{ style.varName }}(){% else %}LabelStyle( + font: UIFont.{{ style.varName }}(){% if style.supportsDynamicType %}, + fontMetrics: UIFontMetrics(forTextStyle: .{{ style.type }}){% endif %}{% if style.lineHeight != 0 %}, + lineHeight: {{ style.lineHeight }}{% endif %}{% if style.tracking != 0 %}, + tracking: {{ style.tracking}}{% endif %} + ){% endif %} + } +} +{% endfor %} +""") + +private let labelStyleExtensionSwiftContents = Template(templateString: """ +\(header) + +import UIKit + +public extension LabelStyle { + {% for style in styles %} + static func {{ style.varName }}() -> LabelStyle { LabelStyle( font: UIFont.{{ style.varName }}(){% if style.supportsDynamicType %}, fontMetrics: UIFontMetrics(forTextStyle: .{{ style.type }}){% endif %}{% if style.lineHeight != 0 %}, @@ -229,8 +281,8 @@ public final class {{ style.className }}Label: Label { tracking: {{ style.tracking}}{% endif %} ) } + {% endfor %} } -{% endfor %} """) private let labelStyleSwiftContents = """ @@ -238,7 +290,7 @@ private let labelStyleSwiftContents = """ import UIKit -struct LabelStyle { +public struct LabelStyle { let font: UIFont let fontMetrics: UIFontMetrics? @@ -252,7 +304,7 @@ struct LabelStyle { self.tracking = tracking } - func attributes(for alignment: NSTextAlignment, lineBreakMode: NSLineBreakMode) -> [NSAttributedString.Key: Any] { + public func attributes(for alignment: NSTextAlignment, lineBreakMode: NSLineBreakMode) -> [NSAttributedString.Key: Any] { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = alignment diff --git a/Tests/XcodeExportTests/XcodeTypographyExporterTests.swift b/Tests/XcodeExportTests/XcodeTypographyExporterTests.swift index f0f6c22..0ebf9fe 100644 --- a/Tests/XcodeExportTests/XcodeTypographyExporterTests.swift +++ b/Tests/XcodeExportTests/XcodeTypographyExporterTests.swift @@ -5,9 +5,15 @@ import FigmaExportCore final class XcodeTypographyExporterTests: XCTestCase { func testExportUIKitFonts() throws { - let output = XcodeTypographyOutput( + let fontUrls = XcodeTypographyOutput.FontURLs( fontExtensionURL: URL(string: "~/UIFont+extension.swift")! ) + let labelUrls = XcodeTypographyOutput.LabelURLs() + let urls = XcodeTypographyOutput.URLs( + fonts: fontUrls, + labels: labelUrls + ) + let output = XcodeTypographyOutput(urls: urls) let exporter = XcodeTypographyExporter(output: output) let styles = [ @@ -82,8 +88,16 @@ final class XcodeTypographyExporterTests: XCTestCase { } func testExportUIKitFontsWithObjc() throws { + let fontUrls = XcodeTypographyOutput.FontURLs( + fontExtensionURL: URL(string: "~/UIFont+extension.swift")! + ) + let labelUrls = XcodeTypographyOutput.LabelURLs() + let urls = XcodeTypographyOutput.URLs( + fonts: fontUrls, + labels: labelUrls + ) let output = XcodeTypographyOutput( - fontExtensionURL: URL(string: "~/UIFont+extension.swift")!, + urls: urls, addObjcAttribute: true ) let exporter = XcodeTypographyExporter(output: output) @@ -160,9 +174,15 @@ final class XcodeTypographyExporterTests: XCTestCase { } func testExportSwiftUIFonts() throws { - let output = XcodeTypographyOutput( + let fontUrls = XcodeTypographyOutput.FontURLs( swiftUIFontExtensionURL: URL(string: "~/Font+extension.swift")! ) + let labelUrls = XcodeTypographyOutput.LabelURLs() + let urls = XcodeTypographyOutput.URLs( + fonts: fontUrls, + labels: labelUrls + ) + let output = XcodeTypographyOutput(urls: urls) let exporter = XcodeTypographyExporter(output: output) let styles = [ @@ -215,11 +235,257 @@ final class XcodeTypographyExporterTests: XCTestCase { ) } - func testExportLabel() throws { + func testExportStyleExtensions() throws { + let fontUrls = XcodeTypographyOutput.FontURLs() + let labelUrls = XcodeTypographyOutput.LabelURLs( + labelsDirectory: URL(string: "~/")!, + labelStyleExtensionsURL: URL(string: "~/LabelStyle+extension.swift")! + ) + let urls = XcodeTypographyOutput.URLs( + fonts: fontUrls, + labels: labelUrls + ) let output = XcodeTypographyOutput( - generateLabels:true, + urls: urls, + generateLabels: true + ) + let exporter = XcodeTypographyExporter(output: output) + + let styles = [ + makeTextStyle(name: "largeTitle", fontName: "PTSans-Bold", fontStyle: .largeTitle, fontSize: 34, lineHeight: nil), + makeTextStyle(name: "header", fontName: "PTSans-Bold", fontSize: 20, lineHeight: nil), + makeTextStyle(name: "body", fontName: "PTSans-Regular", fontStyle: .body, fontSize: 16, lineHeight: nil, letterSpacing: 1.2), + makeTextStyle(name: "caption", fontName: "PTSans-Regular", fontStyle: .footnote, fontSize: 14, lineHeight: 20) + ] + let files = try exporter.export(textStyles: styles) + + let contentsLabel = """ + \(header) + + import UIKit + + public class Label: UILabel { + + var style: LabelStyle? { nil } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + updateText() + } + } + + convenience init(text: String?, textColor: UIColor) { + self.init() + self.text = text + self.textColor = textColor + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + updateText() + } + + private func commonInit() { + font = style?.font + adjustsFontForContentSizeCategory = true + } + + private func updateText() { + text = super.text + } + + public override var text: String? { + get { + guard style?.attributes != nil else { + return super.text + } + + return attributedText?.string + } + set { + guard let style = style else { + super.text = newValue + return + } + + guard let newText = newValue else { + attributedText = nil + super.text = nil + return + } + + let attributes = style.attributes(for: textAlignment, lineBreakMode: lineBreakMode) + attributedText = NSAttributedString(string: newText, attributes: attributes) + } + } + + } + + public final class LargeTitleLabel: Label { + + override var style: LabelStyle? { + .largeTitle() + } + } + + public final class HeaderLabel: Label { + + override var style: LabelStyle? { + .header() + } + } + + public final class BodyLabel: Label { + + override var style: LabelStyle? { + .body() + } + } + + public final class CaptionLabel: Label { + + override var style: LabelStyle? { + .caption() + } + } + + """ + + let contentsLabelStyle = """ + \(header) + + import UIKit + + public struct LabelStyle { + + let font: UIFont + let fontMetrics: UIFontMetrics? + let lineHeight: CGFloat? + let tracking: CGFloat + + init(font: UIFont, fontMetrics: UIFontMetrics? = nil, lineHeight: CGFloat? = nil, tracking: CGFloat = 0) { + self.font = font + self.fontMetrics = fontMetrics + self.lineHeight = lineHeight + self.tracking = tracking + } + + public func attributes(for alignment: NSTextAlignment, lineBreakMode: NSLineBreakMode) -> [NSAttributedString.Key: Any] { + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = alignment + paragraphStyle.lineBreakMode = lineBreakMode + + var baselineOffset: CGFloat = .zero + + if let lineHeight = lineHeight { + let scaledLineHeight: CGFloat = fontMetrics?.scaledValue(for: lineHeight) ?? lineHeight + paragraphStyle.minimumLineHeight = scaledLineHeight + paragraphStyle.maximumLineHeight = scaledLineHeight + + baselineOffset = (scaledLineHeight - font.lineHeight) / 4.0 + } + + return [ + NSAttributedString.Key.paragraphStyle: paragraphStyle, + NSAttributedString.Key.kern: tracking, + NSAttributedString.Key.baselineOffset: baselineOffset, + NSAttributedString.Key.font: font + ] + } + } + + """ + + let styleExtensionContent = """ + \(header) + + import UIKit + + public extension LabelStyle { + + static func largeTitle() -> LabelStyle { + LabelStyle( + font: UIFont.largeTitle(), + fontMetrics: UIFontMetrics(forTextStyle: .largeTitle) + ) + } + + static func header() -> LabelStyle { + LabelStyle( + font: UIFont.header() + ) + } + + static func body() -> LabelStyle { + LabelStyle( + font: UIFont.body(), + fontMetrics: UIFontMetrics(forTextStyle: .body), + tracking: 1.2 + ) + } + + static func caption() -> LabelStyle { + LabelStyle( + font: UIFont.caption(), + fontMetrics: UIFontMetrics(forTextStyle: .footnote), + lineHeight: 20.0 + ) + } + + } + """ + + XCTAssertEqual(files.count, 3, "Must be generated 3 files but generated \(files.count)") + XCTAssertEqual( + files, + [ + FileContents( + destination: Destination( + directory: URL(string: "~/")!, + file: URL(string: "Label.swift")! + ), + data: contentsLabel.data(using: .utf8)! + ), + FileContents( + destination: Destination( + directory: URL(string: "~/")!, + file: URL(string: "LabelStyle.swift")! + ), + data: contentsLabelStyle.data(using: .utf8)! + ), + FileContents( + destination: Destination( + directory: URL(string: "~/")!, + file: URL(string: "LabelStyle+extension.swift")! + ), + data: styleExtensionContent.data(using: .utf8)! + ) + ] + ) + } + + func testExportLabel() throws { + let fontUrls = XcodeTypographyOutput.FontURLs() + let labelUrls = XcodeTypographyOutput.LabelURLs( labelsDirectory: URL(string: "~/")! ) + let urls = XcodeTypographyOutput.URLs( + fonts: fontUrls, + labels: labelUrls + ) + let output = XcodeTypographyOutput( + urls: urls, + generateLabels: true + ) let exporter = XcodeTypographyExporter(output: output) let styles = [ @@ -348,7 +614,7 @@ final class XcodeTypographyExporterTests: XCTestCase { import UIKit - struct LabelStyle { + public struct LabelStyle { let font: UIFont let fontMetrics: UIFontMetrics? @@ -362,7 +628,7 @@ final class XcodeTypographyExporterTests: XCTestCase { self.tracking = tracking } - func attributes(for alignment: NSTextAlignment, lineBreakMode: NSLineBreakMode) -> [NSAttributedString.Key: Any] { + public func attributes(for alignment: NSTextAlignment, lineBreakMode: NSLineBreakMode) -> [NSAttributedString.Key: Any] { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = alignment