Swiftgram/submodules/TranslateUI/Sources/TranslateScreen.swift
2024-10-11 18:34:44 +04:00

1166 lines
59 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import PresentationDataUtils
import Speak
import ComponentFlow
import ViewControllerComponent
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import BundleIconComponent
import UndoUI
private func generateExpandBackground(size: CGSize, color: UIColor) -> UIImage {
return generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
var locations: [CGFloat] = [0.0, 1.0]
let colors: [CGColor] = [color.withAlphaComponent(0.0).cgColor, color.cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 40.0, y: size.height), options: CGGradientDrawingOptions())
context.setFillColor(color.cgColor)
context.fill(CGRect(origin: CGPoint(x: 40.0, y: 0.0), size: CGSize(width: size.width - 40.0, height: size.height)))
})!
}
private final class TranslateScreenComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let text: String
let entities: [MessageTextEntity]
let fromLanguage: String?
let toLanguage: String
let copyTranslation: ((String) -> Void)?
let changeLanguage: (String, String, @escaping (String, String) -> Void) -> Void
let expand: () -> Void
init(context: AccountContext, text: String, entities: [MessageTextEntity], fromLanguage: String?, toLanguage: String, copyTranslation: ((String) -> Void)?, changeLanguage: @escaping (String, String, @escaping (String, String) -> Void) -> Void, expand: @escaping () -> Void) {
self.context = context
self.text = text
self.entities = entities
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.copyTranslation = copyTranslation
self.changeLanguage = changeLanguage
self.expand = expand
}
static func ==(lhs: TranslateScreenComponent, rhs: TranslateScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.entities != rhs.entities {
return false
}
if lhs.fromLanguage != rhs.fromLanguage {
return false
}
if lhs.toLanguage != rhs.toLanguage {
return false
}
return true
}
final class State: ComponentState {
private let context: AccountContext
var fromLanguage: String?
let text: String
var textExpanded: Bool = false
var toLanguage: String
var translatedText: String?
private let expand: () -> Void
private var translationDisposable = MetaDisposable()
fileprivate var isSpeakingOriginalText: Bool = false
fileprivate var isSpeakingTranslatedText: Bool = false
private var speechHolder: SpeechSynthesizerHolder?
fileprivate var availableSpeakLanguages: Set<String>
fileprivate var moreBackgroundImage: (CGSize, UIImage, UIColor)?
init(context: AccountContext, fromLanguage: String?, text: String, toLanguage: String, expand: @escaping () -> Void) {
self.context = context
self.text = text
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.expand = expand
self.availableSpeakLanguages = supportedSpeakLanguages()
super.init()
self.translationDisposable.set((context.engine.messages.translate(text: text, toLang: toLanguage) |> deliverOnMainQueue).start(next: { [weak self] text in
guard let strongSelf = self else {
return
}
strongSelf.translatedText = text?.0
strongSelf.updated(transition: .immediate)
}, error: { error in
}))
}
deinit {
self.speechHolder?.stop()
self.translationDisposable.dispose()
}
func changeLanguage(fromLanguage: String, toLanguage: String) {
guard self.fromLanguage != fromLanguage || self.toLanguage != toLanguage else {
return
}
self.fromLanguage = fromLanguage
self.toLanguage = toLanguage
self.translatedText = nil
self.updated(transition: .immediate)
self.translationDisposable.set((self.context.engine.messages.translate(text: text, toLang: toLanguage) |> deliverOnMainQueue).start(next: { [weak self] text in
guard let strongSelf = self else {
return
}
strongSelf.translatedText = text?.0
strongSelf.updated(transition: .immediate)
}, error: { error in
}))
}
func expandText() {
self.textExpanded = true
self.updated(transition: .immediate)
self.expand()
}
func speakOriginalText() {
if let speechHolder = self.speechHolder {
self.speechHolder = nil
speechHolder.stop()
}
if self.isSpeakingOriginalText {
self.isSpeakingOriginalText = false
} else {
self.isSpeakingTranslatedText = false
self.isSpeakingOriginalText = true
self.speechHolder = speakText(context: self.context, text: self.text)
self.speechHolder?.completion = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.isSpeakingOriginalText = false
strongSelf.updated(transition: .immediate)
}
}
self.updated(transition: .immediate)
}
func speakTranslatedText() {
guard let translatedText = self.translatedText else {
return
}
if let speechHolder = self.speechHolder {
self.speechHolder = nil
speechHolder.stop()
}
if self.isSpeakingTranslatedText {
self.isSpeakingTranslatedText = false
} else {
self.isSpeakingOriginalText = false
self.isSpeakingTranslatedText = true
self.speechHolder = speakText(context: self.context, text: translatedText)
self.speechHolder?.completion = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.isSpeakingTranslatedText = false
strongSelf.updated(transition: .immediate)
}
}
self.updated(transition: .immediate)
}
}
func makeState() -> State {
return State(context: self.context, fromLanguage: self.fromLanguage, text: self.text, toLanguage: self.toLanguage, expand: self.expand)
}
static var body: Body {
let textBackground = Child(RoundedRectangle.self)
let originalTitle = Child(MultilineTextComponent.self)
let originalText = Child(MultilineTextComponent.self)
let originalMoreBackground = Child(Image.self)
let originalMoreButton = Child(Button.self)
let originalSpeakButton = Child(Button.self)
let translationTitle = Child(MultilineTextComponent.self)
let translationText = Child(MultilineTextComponent.self)
let translationPlaceholder = Child(RoundedRectangle.self)
let translationSpeakButton = Child(Button.self)
let copyButton = Child(TranslateButtonComponent.self)
let changeLanguageButton = Child(TranslateButtonComponent.self)
let textStripe = Child(Rectangle.self)
return { context in
let environment = context.environment[ViewControllerComponentContainer.Environment.self].value
let state = context.state
let theme = environment.theme
let strings = environment.strings
let topInset: CGFloat = environment.navigationHeight + 22.0
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let textTopInset: CGFloat = 16.0
let textSideInset: CGFloat = 16.0
let textSpacing: CGFloat = 5.0
let itemSpacing: CGFloat = 16.0
let itemHeight: CGFloat = 44.0
var languageCode = environment.strings.baseLanguageCode
let rawSuffix = "-raw"
if languageCode.hasSuffix(rawSuffix) {
languageCode = String(languageCode.dropLast(rawSuffix.count))
}
let locale = Locale(identifier: languageCode)
let fromLanguage: String
if let languageCode = state.fromLanguage {
fromLanguage = locale.localizedString(forLanguageCode: languageCode) ?? ""
} else {
fromLanguage = ""
}
let originalTitle = originalTitle.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: fromLanguage, font: Font.medium(13.0), textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: 1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0, height: CGFloat.greatestFiniteMagnitude),
transition: .immediate
)
let originalText = originalText.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: state.text, font: Font.medium(17.0), textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: state.textExpanded ? 0 : 1,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0 - (state.textExpanded ? 30.0 : 0.0), height: context.availableSize.height),
transition: .immediate
)
let toLanguage = locale.localizedString(forLanguageCode: state.toLanguage) ?? ""
let translationTitle = translationTitle.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: toLanguage, font: Font.medium(13.0), textColor: theme.list.itemAccentColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: 1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0, height: CGFloat.greatestFiniteMagnitude),
transition: .immediate
)
let translationTextHeight: CGFloat
var maybeTranslationText: _UpdatedChildComponent? = nil
var maybeTranslationPlaceholder: _UpdatedChildComponent? = nil
if let translatedText = state.translatedText {
maybeTranslationText = translationText.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: translatedText, font: Font.medium(17.0), textColor: theme.list.itemAccentColor, paragraphAlignment: .natural)),
horizontalAlignment: .natural,
maximumNumberOfLines: 0,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0 - 30.0, height: context.availableSize.height),
transition: .immediate
)
translationTextHeight = maybeTranslationText?.size.height ?? 0.0
} else {
maybeTranslationPlaceholder = translationPlaceholder.update(
component: RoundedRectangle(color: theme.list.itemAccentColor.withAlphaComponent(0.17), cornerRadius: 6.0),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0 - 42.0, height: 12.0),
transition: .immediate
)
translationTextHeight = 22.0
}
let textBackgroundOrigin = CGPoint(x: sideInset, y: topInset)
let textStripe = textStripe.update(
component: Rectangle(color: theme.list.itemPlainSeparatorColor),
availableSize: CGSize(width: context.availableSize.width - (sideInset + textSideInset) * 2.0, height: UIScreenPixel),
transition: .immediate
)
let textBackgroundSize = CGSize(width: context.availableSize.width - sideInset * 2.0, height: textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing)
let textBackground = textBackground.update(
component: RoundedRectangle(color: theme.list.itemBlocksBackgroundColor, cornerRadius: 10.0),
availableSize: textBackgroundSize,
transition: context.transition
)
context.add(textBackground
.position(CGPoint(x: textBackgroundOrigin.x + textBackgroundSize.width / 2.0, y: topInset + textBackgroundSize.height / 2.0))
)
context.add(textStripe
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + textStripe.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing))
)
context.add(originalTitle
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + originalTitle.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height / 2.0))
)
context.add(originalText
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + originalText.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height / 2.0))
)
if state.textExpanded {
if let fromLanguage = state.fromLanguage, state.availableSpeakLanguages.contains(fromLanguage) {
var checkColor = theme.list.itemCheckColors.foregroundColor
if checkColor.rgb == theme.list.itemPrimaryTextColor.rgb {
checkColor = theme.list.plainBackgroundColor
}
let originalSpeakButton = originalSpeakButton.update(
component: Button(
content: AnyComponent(ZStack([
AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle(
fillColor: theme.list.itemPrimaryTextColor,
size: CGSize(width: 22.0, height: 22.0)
))),
AnyComponentWithIdentity(id: "a", component: AnyComponent(PlayPauseIconComponent(
state: state.isSpeakingOriginalText ? .pause : .play,
tintColor: checkColor,
size: CGSize(width: 18.0, height: 18.0)
))),
])),
action: { [weak state] in
guard let state = state else {
return
}
state.speakOriginalText()
}
).minSize(CGSize(width: 44.0, height: 44.0)),
availableSize: CGSize(width: 22.0, height: 22.0),
transition: .immediate
)
context.add(originalSpeakButton
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - originalSpeakButton.size.width / 2.0 - 3.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height - originalSpeakButton.size.height / 2.0 - 2.0))
)
}
} else {
let originalMoreButton = originalMoreButton.update(
component: Button(
content: AnyComponent(Text(text: strings.PeerInfo_BioExpand, font: Font.regular(17.0), color: theme.list.itemAccentColor)),
action: { [weak state] in
guard let state = state else {
return
}
state.expandText()
}
),
availableSize: context.availableSize,
transition: .immediate
)
let originalMoreBackgroundSize = CGSize(width: originalMoreButton.size.width + 50.0, height: originalMoreButton.size.height)
let originalMoreBackgroundImage: UIImage
let backgroundColor = theme.list.itemBlocksBackgroundColor
if let (size, image, color) = state.moreBackgroundImage, size == originalMoreBackgroundSize && color == backgroundColor {
originalMoreBackgroundImage = image
} else {
originalMoreBackgroundImage = generateExpandBackground(size: originalMoreBackgroundSize, color: backgroundColor)
state.moreBackgroundImage = (originalMoreBackgroundSize, originalMoreBackgroundImage, backgroundColor)
}
let originalMoreBackground = originalMoreBackground.update(
component: Image(image: originalMoreBackgroundImage, tintColor: backgroundColor),
availableSize: originalMoreBackgroundSize,
transition: .immediate
)
context.add(originalMoreBackground
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - originalMoreBackground.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalMoreBackground.size.height / 2.0 - 1.0))
)
context.add(originalMoreButton
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - originalMoreButton.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height / 2.0 - 1.0))
)
}
context.add(translationTitle
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + translationTitle.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height / 2.0))
)
if let translationText = maybeTranslationText {
context.add(translationText
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + translationText.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationText.size.height / 2.0))
)
} else if let translationPlaceholder = maybeTranslationPlaceholder {
context.add(translationPlaceholder
.position(CGPoint(x: textBackgroundOrigin.x + textSideInset + translationPlaceholder.size.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationPlaceholder.size.height / 2.0 + 4.0))
)
}
if state.availableSpeakLanguages.contains(state.toLanguage) {
let translationSpeakButton = translationSpeakButton.update(
component: Button(
content: AnyComponent(ZStack([
AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle(
fillColor: theme.list.itemAccentColor,
size: CGSize(width: 22.0, height: 22.0)
))),
AnyComponentWithIdentity(id: "a", component: AnyComponent(PlayPauseIconComponent(
state: state.isSpeakingTranslatedText ? .pause : .play,
tintColor: theme.list.itemCheckColors.foregroundColor,
size: CGSize(width: 18.0, height: 18.0)
))),
])),
action: { [weak state] in
guard let state = state else {
return
}
state.speakTranslatedText()
}
).minSize(CGSize(width: 44.0, height: 44.0)),
availableSize: CGSize(width: 22.0, height: 22.0),
transition: .immediate
)
context.add(translationSpeakButton
.position(CGPoint(x: context.availableSize.width - sideInset - textSideInset - translationSpeakButton.size.width / 2.0 - 3.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight - translationSpeakButton.size.height / 2.0 - 2.0))
)
}
let buttonsSpacing: CGFloat = 24.0
let smallSectionSpacing: CGFloat = 8.0
var buttonsHeight: CGFloat = 0.0
let component = context.component
if component.copyTranslation != nil {
let copyButton = copyButton.update(
component: TranslateButtonComponent(
theme: theme,
title: strings.Translate_CopyTranslation,
icon: "Chat/Context Menu/Copy",
isEnabled: state.translatedText != nil,
action: { [weak component] in
component?.copyTranslation?(state.translatedText ?? "")
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: itemHeight),
transition: context.transition
)
context.add(copyButton
.position(CGPoint(x: context.availableSize.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing + buttonsSpacing + copyButton.size.height / 2.0))
)
buttonsHeight += copyButton.size.height + smallSectionSpacing
}
let changeLanguageButton = changeLanguageButton.update(
component: TranslateButtonComponent(
theme: theme,
title: strings.Translate_ChangeLanguage,
icon: "Chat/Context Menu/Translate",
isEnabled: true,
action: { [weak component] in
component?.changeLanguage(state.fromLanguage ?? "", state.toLanguage, { fromLang, toLang in
state.changeLanguage(fromLanguage: fromLang, toLanguage: toLang)
})
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: itemHeight),
transition: context.transition
)
context.add(changeLanguageButton
.position(CGPoint(x: context.availableSize.width / 2.0, y: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing + buttonsSpacing + buttonsHeight + changeLanguageButton.size.height / 2.0))
)
buttonsHeight += changeLanguageButton.size.height
let contentSize = CGSize(width: context.availableSize.width, height: textBackgroundOrigin.y + textTopInset + originalTitle.size.height + textSpacing + originalText.size.height + itemSpacing + textTopInset + translationTitle.size.height + textSpacing + translationTextHeight + itemSpacing + buttonsSpacing + buttonsHeight + environment.safeInsets.bottom + 44.0)
return contentSize
}
}
}
public class TranslateScreen: ViewController {
final class Node: ViewControllerTracingNode, ASScrollViewDelegate, ASGestureRecognizerDelegate {
private var presentationData: PresentationData
private weak var controller: TranslateScreen?
private let component: AnyComponent<ViewControllerComponentContainer.Environment>
private let theme: PresentationTheme?
let dim: ASDisplayNode
let wrappingView: UIView
let containerView: UIView
let scrollView: UIScrollView
let hostView: ComponentHostView<ViewControllerComponentContainer.Environment>
private(set) var isExpanded = false
private var panGestureRecognizer: UIPanGestureRecognizer?
private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?, listNode: ListView?)?
private var currentIsVisible: Bool = false
private var currentLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)?
fileprivate var temporaryDismiss = false
init(context: AccountContext, controller: TranslateScreen, component: AnyComponent<ViewControllerComponentContainer.Environment>, theme: PresentationTheme?) {
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.controller = controller
self.component = component
self.theme = theme
let effectiveTheme = theme ?? self.presentationData.theme
self.dim = ASDisplayNode()
self.dim.alpha = 0.0
self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25)
self.wrappingView = UIView()
self.containerView = UIView()
self.scrollView = UIScrollView()
self.hostView = ComponentHostView()
super.init()
self.scrollView.delegate = self.wrappedScrollViewDelegate
self.scrollView.showsVerticalScrollIndicator = false
self.containerView.clipsToBounds = true
self.containerView.backgroundColor = effectiveTheme.list.blocksBackgroundColor
self.addSubnode(self.dim)
self.view.addSubview(self.wrappingView)
self.wrappingView.addSubview(self.containerView)
self.containerView.addSubview(self.scrollView)
self.scrollView.addSubview(self.hostView)
}
override func didLoad() {
super.didLoad()
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))
panRecognizer.delegate = self.wrappedGestureRecognizerDelegate
panRecognizer.delaysTouchesBegan = false
panRecognizer.cancelsTouchesInView = true
self.panGestureRecognizer = panRecognizer
self.wrappingView.addGestureRecognizer(panRecognizer)
self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
self.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate)
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.controller?.dismiss(animated: true)
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let (layout, _) = self.currentLayout {
if case .regular = layout.metrics.widthClass {
return false
}
}
return true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let contentOffset = self.scrollView.contentOffset.y
self.controller?.navigationBar?.updateBackgroundAlpha(min(30.0, contentOffset) / 30.0, transition: .immediate)
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer {
return true
}
return false
}
private var isDismissing = false
func animateIn() {
ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear).updateAlpha(node: self.dim, alpha: 1.0)
let targetPosition = self.containerView.center
let startPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height)
self.containerView.center = startPosition
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
transition.animateView(allowUserInteraction: true, {
self.containerView.center = targetPosition
}, completion: { _ in
})
}
func animateOut(completion: @escaping () -> Void = {}) {
self.isDismissing = true
let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut)
positionTransition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.center.x, y: self.bounds.height + self.containerView.bounds.height / 2.0), completion: { [weak self] _ in
self?.controller?.dismiss(animated: false, completion: completion)
})
let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut)
alphaTransition.updateAlpha(node: self.dim, alpha: 0.0)
if !self.temporaryDismiss {
self.controller?.updateModalStyleOverlayTransitionFactor(0.0, transition: positionTransition)
}
}
func containerLayoutUpdated(layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ComponentTransition) {
self.currentLayout = (layout, navigationHeight)
if let controller = self.controller, let navigationBar = controller.navigationBar, navigationBar.view.superview !== self.wrappingView {
self.containerView.addSubview(navigationBar.view)
}
self.dim.frame = CGRect(origin: CGPoint(x: 0.0, y: -layout.size.height), size: CGSize(width: layout.size.width, height: layout.size.height * 3.0))
var effectiveExpanded = self.isExpanded
if case .regular = layout.metrics.widthClass {
effectiveExpanded = true
}
let isLandscape = layout.orientation == .landscape
let edgeTopInset = isLandscape ? 0.0 : self.defaultTopInset
let topInset: CGFloat
if let (panInitialTopInset, panOffset, _, _) = self.panGestureArguments {
if effectiveExpanded {
topInset = min(edgeTopInset, panInitialTopInset + max(0.0, panOffset))
} else {
topInset = max(0.0, panInitialTopInset + min(0.0, panOffset))
}
} else {
topInset = effectiveExpanded ? 0.0 : edgeTopInset
}
transition.setFrame(view: self.wrappingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: layout.size), completion: nil)
let modalProgress = isLandscape ? 0.0 : (1.0 - topInset / self.defaultTopInset)
self.controller?.updateModalStyleOverlayTransitionFactor(modalProgress, transition: transition.containedViewLayoutTransition)
let clipFrame: CGRect
if layout.metrics.widthClass == .compact {
self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.25)
if isLandscape {
self.containerView.layer.cornerRadius = 0.0
} else {
self.containerView.layer.cornerRadius = 10.0
}
if #available(iOS 11.0, *) {
if layout.safeInsets.bottom.isZero {
self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
} else {
self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
}
}
if isLandscape {
clipFrame = CGRect(origin: CGPoint(), size: layout.size)
} else {
let coveredByModalTransition: CGFloat = 0.0
var containerTopInset: CGFloat = 10.0
if let statusBarHeight = layout.statusBarHeight {
containerTopInset += statusBarHeight
}
let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: CGSize(width: layout.size.width, height: layout.size.height - containerTopInset))
let maxScale: CGFloat = (layout.size.width - 16.0 * 2.0) / layout.size.width
let containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition
let maxScaledTopInset: CGFloat = containerTopInset - 10.0
let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition
let containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0))
clipFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height)
}
} else {
self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4)
self.containerView.layer.cornerRadius = 10.0
let verticalInset: CGFloat = 44.0
let maxSide = max(layout.size.width, layout.size.height)
let minSide = min(layout.size.width, layout.size.height)
let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0)
clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize)
}
transition.setFrame(view: self.containerView, frame: clipFrame)
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: clipFrame.size), completion: nil)
let environment = ViewControllerComponentContainer.Environment(
statusBarHeight: 0.0,
navigationHeight: navigationHeight,
safeInsets: UIEdgeInsets(top: layout.intrinsicInsets.top + layout.safeInsets.top, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom + layout.safeInsets.bottom, right: layout.safeInsets.right),
additionalInsets: layout.additionalInsets,
inputHeight: layout.inputHeight ?? 0.0,
metrics: layout.metrics,
deviceMetrics: layout.deviceMetrics,
orientation: layout.metrics.orientation,
isVisible: self.currentIsVisible,
theme: self.theme ?? self.presentationData.theme,
strings: self.presentationData.strings,
dateTimeFormat: self.presentationData.dateTimeFormat,
controller: { [weak self] in
return self?.controller
}
)
var contentSize = self.hostView.update(
transition: transition,
component: self.component,
environment: {
environment
},
forceUpdate: true,
containerSize: CGSize(width: clipFrame.size.width, height: 10000.0)
)
contentSize.height = max(layout.size.height - navigationHeight, contentSize.height)
transition.setFrame(view: self.hostView, frame: CGRect(origin: CGPoint(), size: contentSize), completion: nil)
self.scrollView.contentSize = contentSize
}
private var didPlayAppearAnimation = false
func updateIsVisible(isVisible: Bool) {
if self.currentIsVisible == isVisible {
return
}
self.currentIsVisible = isVisible
guard let currentLayout = self.currentLayout else {
return
}
self.containerLayoutUpdated(layout: currentLayout.layout, navigationHeight: currentLayout.navigationHeight, transition: .immediate)
if !self.didPlayAppearAnimation {
self.didPlayAppearAnimation = true
self.animateIn()
}
}
private var defaultTopInset: CGFloat {
guard let (layout, _) = self.currentLayout else{
return 210.0
}
if case .compact = layout.metrics.widthClass {
var factor: CGFloat = 0.2488
if layout.size.width <= 320.0 {
factor = 0.15
}
return floor(max(layout.size.width, layout.size.height) * factor)
} else {
return 210.0
}
}
private func findScrollView(view: UIView?) -> (UIScrollView, ListView?)? {
if let view = view {
if let view = view as? UIScrollView {
return (view, nil)
}
if let node = view.asyncdisplaykit_node as? ListView {
return (node.scroller, node)
}
return findScrollView(view: view.superview)
} else {
return nil
}
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
guard let (layout, navigationHeight) = self.currentLayout else {
return
}
let isLandscape = layout.orientation == .landscape
let edgeTopInset = isLandscape ? 0.0 : defaultTopInset
switch recognizer.state {
case .began:
let point = recognizer.location(in: self.view)
let currentHitView = self.hitTest(point, with: nil)
var scrollViewAndListNode = self.findScrollView(view: currentHitView)
if scrollViewAndListNode?.0.frame.height == self.frame.width {
scrollViewAndListNode = nil
}
let scrollView = scrollViewAndListNode?.0
let listNode = scrollViewAndListNode?.1
let topInset: CGFloat
if self.isExpanded {
topInset = 0.0
} else {
topInset = edgeTopInset
}
self.panGestureArguments = (topInset, 0.0, scrollView, listNode)
case .changed:
guard let (topInset, panOffset, scrollView, listNode) = self.panGestureArguments else {
return
}
let visibleContentOffset = listNode?.visibleContentOffset()
let contentOffset = scrollView?.contentOffset.y ?? 0.0
var translation = recognizer.translation(in: self.view).y
var currentOffset = topInset + translation
let epsilon = 1.0
if case let .known(value) = visibleContentOffset, value <= epsilon {
if let scrollView = scrollView {
scrollView.bounces = false
scrollView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: false)
}
} else if let scrollView = scrollView, contentOffset <= -scrollView.contentInset.top + epsilon {
scrollView.bounces = false
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
} else if let scrollView = scrollView {
translation = panOffset
currentOffset = topInset + translation
if self.isExpanded {
recognizer.setTranslation(CGPoint(), in: self.view)
} else if currentOffset > 0.0 {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
}
}
self.panGestureArguments = (topInset, translation, scrollView, listNode)
if !self.isExpanded {
if currentOffset > 0.0, let scrollView = scrollView {
scrollView.panGestureRecognizer.setTranslation(CGPoint(), in: scrollView)
}
}
var bounds = self.bounds
if self.isExpanded {
bounds.origin.y = -max(0.0, translation - edgeTopInset)
} else {
bounds.origin.y = -translation
}
bounds.origin.y = min(0.0, bounds.origin.y)
self.bounds = bounds
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate)
case .ended:
guard let (currentTopInset, panOffset, scrollView, listNode) = self.panGestureArguments else {
return
}
self.panGestureArguments = nil
let visibleContentOffset = listNode?.visibleContentOffset()
let contentOffset = scrollView?.contentOffset.y ?? 0.0
let translation = recognizer.translation(in: self.view).y
var velocity = recognizer.velocity(in: self.view)
if self.isExpanded {
if case let .known(value) = visibleContentOffset, value > 0.1 {
velocity = CGPoint()
} else if case .unknown = visibleContentOffset {
velocity = CGPoint()
} else if contentOffset > 0.1 {
velocity = CGPoint()
}
}
var bounds = self.bounds
if self.isExpanded {
bounds.origin.y = -max(0.0, translation - edgeTopInset)
} else {
bounds.origin.y = -translation
}
bounds.origin.y = min(0.0, bounds.origin.y)
scrollView?.bounces = true
let offset = currentTopInset + panOffset
let topInset: CGFloat = edgeTopInset
var dismissing = false
if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0) {
self.controller?.dismiss(animated: true, completion: nil)
dismissing = true
} else if self.isExpanded {
if velocity.y > 300.0 || offset > topInset / 2.0 {
self.isExpanded = false
if let listNode = listNode {
listNode.scroller.setContentOffset(CGPoint(), animated: false)
} else if let scrollView = scrollView {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
}
let distance = topInset - offset
let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance)
let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity))
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: ComponentTransition(transition))
} else {
self.isExpanded = true
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut)))
}
} else if (velocity.y < -300.0 || offset < topInset / 2.0) {
if velocity.y > -2200.0 && velocity.y < -300.0, let listNode = listNode {
DispatchQueue.main.async {
listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
}
let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset)
let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity))
self.isExpanded = true
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: ComponentTransition(transition))
} else {
if let listNode = listNode {
listNode.scroller.setContentOffset(CGPoint(), animated: false)
} else if let scrollView = scrollView {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
}
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut)))
}
if !dismissing {
var bounds = self.bounds
let previousBounds = bounds
bounds.origin.y = 0.0
self.bounds = bounds
self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue)
}
case .cancelled:
self.panGestureArguments = nil
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: ComponentTransition(.animated(duration: 0.3, curve: .easeInOut)))
default:
break
}
}
func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) {
guard isExpanded != self.isExpanded else {
return
}
self.isExpanded = isExpanded
guard let (layout, navigationHeight) = self.currentLayout else {
return
}
self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: ComponentTransition(transition))
}
}
var node: Node {
return self.displayNode as! Node
}
private let context: AccountContext
private let theme: PresentationTheme?
private let component: AnyComponent<ViewControllerComponentContainer.Environment>
private var isInitiallyExpanded = false
private var currentLayout: ContainerViewLayout?
public var pushController: (ViewController) -> Void = { _ in }
public var presentController: (ViewController) -> Void = { _ in }
public var wasDismissed: (() -> Void)?
public convenience init(context: AccountContext, forceTheme: PresentationTheme? = nil, text: String, entities: [MessageTextEntity] = [], canCopy: Bool, fromLanguage: String?, toLanguage: String? = nil, isExpanded: Bool = false, ignoredLanguages: [String]? = nil) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var baseLanguageCode = presentationData.strings.baseLanguageCode
let rawSuffix = "-raw"
if baseLanguageCode.hasSuffix(rawSuffix) {
baseLanguageCode = String(baseLanguageCode.dropLast(rawSuffix.count))
}
let dontTranslateLanguages = effectiveIgnoredTranslationLanguages(context: context, ignoredLanguages: ignoredLanguages)
var toLanguage = toLanguage ?? baseLanguageCode
if toLanguage == fromLanguage {
if fromLanguage == "en" {
toLanguage = dontTranslateLanguages.first(where: { $0 != "en" }) ?? "en"
} else {
toLanguage = "en"
}
}
toLanguage = normalizeTranslationLanguage(toLanguage)
var copyTranslationImpl: ((String) -> Void)?
var changeLanguageImpl: ((String, String, @escaping (String, String) -> Void) -> Void)?
var expandImpl: (() -> Void)?
self.init(context: context, component: TranslateScreenComponent(context: context, text: text, entities: entities, fromLanguage: fromLanguage, toLanguage: toLanguage, copyTranslation: !canCopy ? nil : { text in
copyTranslationImpl?(text)
}, changeLanguage: { fromLang, toLang, completion in
changeLanguageImpl?(fromLang, toLang, completion)
}, expand: {
expandImpl?()
}), theme: forceTheme)
self.isInitiallyExpanded = isExpanded
self.title = presentationData.strings.Translate_Title
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(self.cancelPressed))
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
copyTranslationImpl = { [weak self] text in
UIPasteboard.general.string = text
let content = UndoOverlayContent.copy(text: presentationData.strings.Conversation_TextCopied)
self?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
self?.dismiss(animated: true, completion: nil)
}
changeLanguageImpl = { [weak self] fromLang, toLang, completion in
let pushController = self?.pushController
let presentController = self?.presentController
let controller = languageSelectionController(context: context, forceTheme: forceTheme, fromLanguage: fromLang, toLanguage: toLang, completion: { fromLang, toLang in
let controller = TranslateScreen(context: context, forceTheme: forceTheme, text: text, canCopy: canCopy, fromLanguage: fromLang, toLanguage: toLang, isExpanded: true, ignoredLanguages: ignoredLanguages)
controller.pushController = pushController ?? { _ in }
controller.presentController = presentController ?? { _ in }
presentController?(controller)
})
self?.node.temporaryDismiss = true
self?.dismiss(animated: true, completion: nil)
pushController?(controller)
}
expandImpl = { [weak self] in
self?.node.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring))
if let currentLayout = self?.currentLayout {
self?.containerLayoutUpdated(currentLayout, transition: .animated(duration: 0.4, curve: .spring))
}
}
}
private init<C: Component>(context: AccountContext, component: C, theme: PresentationTheme? = nil) where C.EnvironmentType == ViewControllerComponentContainer.Environment {
self.context = context
self.component = AnyComponent(component)
self.theme = theme
var presentationData = context.sharedContext.currentPresentationData.with { $0 }
if let theme {
presentationData = presentationData.withUpdated(theme: theme)
}
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: presentationData))
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func cancelPressed() {
self.dismiss(animated: true, completion: nil)
}
override open func loadDisplayNode() {
self.displayNode = Node(context: self.context, controller: self, component: self.component, theme: self.theme)
if self.isInitiallyExpanded {
(self.displayNode as! Node).update(isExpanded: true, transition: .immediate)
}
self.displayNodeDidLoad()
}
public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
self.view.endEditing(true)
let wasDismissed = self.wasDismissed
if flag {
self.node.animateOut(completion: {
super.dismiss(animated: false, completion: {})
wasDismissed?()
completion?()
})
} else {
super.dismiss(animated: false, completion: {})
wasDismissed?()
completion?()
}
}
override open func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.node.updateIsVisible(isVisible: true)
}
override open func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.node.updateIsVisible(isVisible: false)
}
override public func updateNavigationBarLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
var navigationLayout = self.navigationLayout(layout: layout)
var navigationFrame = navigationLayout.navigationFrame
var layout = layout
if case .regular = layout.metrics.widthClass {
let verticalInset: CGFloat = 44.0
let maxSide = max(layout.size.width, layout.size.height)
let minSide = min(layout.size.width, layout.size.height)
let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0)
let clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize)
navigationFrame.size.width = clipFrame.width
layout.size = clipFrame.size
}
navigationFrame.size.height = 56.0
navigationLayout.navigationFrame = navigationFrame
navigationLayout.defaultContentHeight = 56.0
layout.statusBarHeight = nil
self.applyNavigationBarLayout(layout, navigationLayout: navigationLayout, additionalBackgroundHeight: 0.0, transition: transition)
}
override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
self.currentLayout = layout
super.containerLayoutUpdated(layout, transition: transition)
let navigationHeight: CGFloat = 56.0
self.node.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: ComponentTransition(transition))
}
}