Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/Stickers/EditorSticker.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit
import UIKit

private class LayerContainerView: UIView {
    let contentLayer: CALayer
    init(contentLayer: CALayer) {
        self.contentLayer = contentLayer
        super.init(frame: .zero)
        layer.addSublayer(contentLayer)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        contentLayer.frame = CGRect(origin: self.frame.origin, size: self.frame.size)
    }
}

// MARK: - EditorSticker

public enum EditorSticker {
    case regular(StickerInfo)
    case story(StorySticker)

    // MARK: StorySticker

    public enum StorySticker {
        case clockDigital(DigitalClockStyle)
        case clockAnalog(AnalogClockStyle)

        @MainActor
        func previewView() -> UIView {
            switch self {
            case .clockDigital(let digitalClockStyle):
                let label = UILabel()
                label.attributedText = digitalClockStyle.attributedString(date: Date())
                label.adjustsFontSizeToFitWidth = true
                return label
            case .clockAnalog(let clockStyle):
                let clockLayer = clockStyle.drawClock(date: Date())
                return LayerContainerView(contentLayer: clockLayer)
            }
        }

        /// A list of story sticker configurations to display in the sticker picker.
        ///
        /// Contains one of each story sticker with each one's default configuration.
        static var pickerStickers: [StorySticker] {
            [
                .clockDigital(.white),
                .clockAnalog(.arabic),
            ]
        }
    }
}

// MARK: DigitalClockStyle

extension EditorSticker.StorySticker {
    public enum DigitalClockStyle: CaseIterable {
        case white
        case black
        case light
        case dark
        case amber

        private var foregroundColor: UIColor {
            switch self {
            case .white, .light, .dark:
                return .ows_white
            case .black:
                return .ows_black
            case .amber:
                return .init(rgbHex: 0xFF7629)
            }
        }

        var backgroundColor: UIColor? {
            switch self {
            case .white, .black:
                return nil
            case .light:
                return .ows_whiteAlpha40
            case .dark:
                return .ows_blackAlpha40
            case .amber:
                return .ows_blackAlpha60
            }
        }

        func attributedString(
            date: Date,
            scaleFactor: CGFloat = 1.0,
        ) -> NSAttributedString {
            let is12HourTime = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: .current)?.contains("a") ?? true
            let timeFormat = is12HourTime ? "h:mm" : "HH:mm"
            let amPMFormat = is12HourTime ? " a" : nil

            let timeFormatter = DateFormatter()
            timeFormatter.dateFormat = timeFormat
            let timeString = timeFormatter.string(from: date)
            let timeFont = UIFont.digitalClockFont(withPointSize: 96 * scaleFactor)
            let timeAttributedString = NSAttributedString(
                string: timeString,
                attributes: [
                    .font: timeFont,
                    .foregroundColor: self.foregroundColor,
                ],
            )

            if let amPMFormat {
                let amPMFormatter = DateFormatter()
                amPMFormatter.dateFormat = amPMFormat
                let amPMString = amPMFormatter.string(from: date)
                let amPMFont = UIFont.regularFont(ofSize: 24 * scaleFactor)
                let amPMAttributedString = NSAttributedString(
                    string: amPMString,
                    attributes: [
                        .font: amPMFont,
                        .foregroundColor: self.foregroundColor,
                    ],
                )
                return timeAttributedString + amPMAttributedString
            }

            return timeAttributedString
        }

        func nextStyle() -> DigitalClockStyle {
            switch self {
            case .white:
                return .black
            case .black:
                return .light
            case .light:
                return .dark
            case .dark:
                return .amber
            case .amber:
                return .white
            }
        }

        func stickerWithNextStyle() -> EditorSticker {
            return .story(.clockDigital(self.nextStyle()))
        }
    }
}

// MARK: AnalogClockStyle

extension EditorSticker.StorySticker {
    public enum AnalogClockStyle: CaseIterable {
        case arabic
        case baton
        case explorer
        case diver

        var backgroundImage: UIImage {
            switch self {
            case .arabic:
                return #imageLiteral(resourceName: "clock-arabic.pdf")
            case .baton:
                return #imageLiteral(resourceName: "clock-baton.pdf")
            case .explorer:
                return #imageLiteral(resourceName: "clock-explorer.pdf")
            case .diver:
                return #imageLiteral(resourceName: "clock-diver.pdf")
            }
        }

        @MainActor
        func drawClock(date: Date) -> CALayer {
            return AnalogClockLayer(style: self, date: date)
        }

        var hourHandImage: UIImage {
            switch self {
            case .arabic:
                return #imageLiteral(resourceName: "clock-arabic-hour.pdf")
            case .baton:
                return #imageLiteral(resourceName: "clock-baton-hour.pdf")
            case .explorer:
                return #imageLiteral(resourceName: "clock-explorer-hour.pdf")
            case .diver:
                return #imageLiteral(resourceName: "clock-diver-hour.pdf")
            }
        }

        var hourHandHeight: CGFloat {
            switch self {
            case .arabic:
                return 1 / 3
            case .baton:
                return 0.35
            case .explorer:
                return 149 / 600
            case .diver:
                return 139 / 600
            }
        }

        var hourHandOffset: CGFloat {
            switch self {
            case .arabic:
                return 0.72
            case .baton:
                return 16 / 21
            case .explorer:
                return 1
            case .diver:
                return 141 / 139
            }
        }

        var minuteHandImage: UIImage {
            switch self {
            case .arabic:
                return #imageLiteral(resourceName: "clock-arabic-minute.pdf")
            case .baton:
                return #imageLiteral(resourceName: "clock-baton-minute.pdf")
            case .explorer:
                return #imageLiteral(resourceName: "clock-explorer-minute.pdf")
            case .diver:
                return #imageLiteral(resourceName: "clock-diver-minute.pdf")
            }
        }

        var minuteHandHeight: CGFloat {
            switch self {
            case .arabic:
                return 280 / 600
            case .baton:
                return 308 / 600
            case .explorer:
                return 229 / 600
            case .diver:
                return 268 / 600
            }
        }

        var minuteHandOffset: CGFloat {
            switch self {
            case .arabic:
                return 4 / 5
            case .baton:
                return 129 / 154
            case .explorer:
                return 1
            case .diver:
                return 1
            }
        }

        var centerImage: UIImage? {
            switch self {
            case .diver:
                return #imageLiteral(resourceName: "clock-diver-center.pdf")
            case .arabic, .baton, .explorer:
                return nil
            }
        }

        func nextStyle() -> AnalogClockStyle {
            switch self {
            case .arabic:
                return .baton
            case .baton:
                return .explorer
            case .explorer:
                return .diver
            case .diver:
                return .arabic
            }
        }

        func stickerWithNextStyle() -> EditorSticker {
            return .story(.clockAnalog(self.nextStyle()))
        }
    }
}

// MARK: - AnalogClockLayer

private class AnalogClockLayer: CALayer {
    typealias Style = EditorSticker.StorySticker.AnalogClockStyle

    private let clockStyle: Style
    private let date: Date
    private let background: CALayer
    private let hourHand: CALayer
    private let minuteHand: CALayer
    private let center: CALayer?

    override var frame: CGRect {
        didSet {
            updateSublayerFrames()
        }
    }

    @MainActor
    init(style: Style, date: Date) {
        self.clockStyle = style
        self.date = date

        background = UIImageView(image: style.backgroundImage).layer

        let hourHandImageView = UIImageView(image: style.hourHandImage)
        hourHandImageView.contentMode = .scaleAspectFit
        hourHand = hourHandImageView.layer

        let minuteHandImageView = UIImageView(image: style.minuteHandImage)
        minuteHandImageView.contentMode = .scaleAspectFit
        minuteHand = minuteHandImageView.layer

        center = style.centerImage.map(UIImageView.init(image:))?.layer

        super.init()
        addSublayer(background)
        addSublayer(hourHand)
        addSublayer(minuteHand)
        if let center {
            addSublayer(center)
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func updateSublayerFrames() {
        let dateComponents = Calendar.current.dateComponents([.hour, .minute], from: date)
        let minutes = CGFloat(dateComponents.minute ?? 0)
        let hours = CGFloat(dateComponents.hour ?? 0) + minutes / 60
//        let minutes = CGFloat.random(in: 0..<60)
//        let hours = CGFloat.random(in: 0..<12)

        background.frame.size = self.frame.size
        transfrom(
            clockHandLayer: hourHand,
            time: hours / 12,
            height: clockStyle.hourHandHeight,
            offset: clockStyle.hourHandOffset,
        )
        transfrom(
            clockHandLayer: minuteHand,
            time: minutes / 60,
            height: clockStyle.minuteHandHeight,
            offset: clockStyle.minuteHandOffset,
        )
        if let center {
            let size: CGFloat = 42 / 600 * self.frame.height
            center.frame = CGRect(
                origin: .init(
                    x: self.frame.width / 2 - size / 2,
                    y: self.frame.height / 2 - size / 2,
                ),
                size: .square(size),
            )
        }
    }

    private func transfrom(
        clockHandLayer hand: CALayer,
        time: CGFloat,
        height: CGFloat,
        offset: CGFloat,
    ) {
        hand.setAffineTransform(.identity)
        hand.frame.size.height = self.frame.height * height
        hand.frame.origin = .init(
            x: self.frame.width / 2 - hand.frame.size.width / 2,
            y: self.frame.height / 2 - hand.frame.size.height / 2,
        )

        hand.anchorPoint = .init(x: 0.5, y: offset)
        hand.setAffineTransform(
            .init(translationX: 0, y: -hand.frame.height * (offset - 0.5))
                .rotated(by: time * 2 * .pi),
        )
    }

}