Initial implementation of channel overscroll navigation

This commit is contained in:
Ali 2021-07-27 17:36:35 +02:00
parent 6cec47b5f7
commit e85f3884d4
27 changed files with 2940 additions and 2 deletions

View File

@ -0,0 +1,14 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ComponentFlow",
module_name = "ComponentFlow",
srcs = glob([
"Source/**/*.swift",
]),
deps = [
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,78 @@
import Foundation
import UIKit
public extension Transition.Appear {
static func `default`(scale: Bool = false, alpha: Bool = false) -> Transition.Appear {
return Transition.Appear { component, view, transition in
if scale {
transition.animateScale(view: view, from: 0.01, to: 1.0)
}
if alpha {
transition.animateAlpha(view: view, from: 0.0, to: 1.0)
}
}
}
static func scaleIn() -> Transition.Appear {
return Transition.Appear { component, view, transition in
transition.animateScale(view: view, from: 0.01, to: 1.0)
}
}
}
public extension Transition.AppearWithGuide {
static func `default`(scale: Bool = false, alpha: Bool = false) -> Transition.AppearWithGuide {
return Transition.AppearWithGuide { component, view, guide, transition in
if scale {
transition.animateScale(view: view, from: 0.01, to: 1.0)
}
if alpha {
transition.animateAlpha(view: view, from: 0.0, to: 1.0)
}
transition.animatePosition(view: view, from: CGPoint(x: guide.x - view.center.x, y: guide.y - view.center.y), to: CGPoint(), additive: true)
}
}
}
public extension Transition.Disappear {
static let `default` = Transition.Disappear { view, transition, completion in
transition.setAlpha(view: view, alpha: 0.0, completion: { _ in
completion()
})
}
}
public extension Transition.DisappearWithGuide {
static func `default`(alpha: Bool = true) -> Transition.DisappearWithGuide {
return Transition.DisappearWithGuide { stage, view, guide, transition, completion in
switch stage {
case .begin:
if alpha {
transition.setAlpha(view: view, alpha: 0.0, completion: { _ in
completion()
})
}
transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: guide.x - view.bounds.width / 2.0, y: guide.y - view.bounds.height / 2.0), size: view.bounds.size), completion: { _ in
if !alpha {
completion()
}
})
case .update:
transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: guide.x - view.bounds.width / 2.0, y: guide.y - view.bounds.height / 2.0), size: view.bounds.size))
}
}
}
}
public extension Transition.Update {
static let `default` = Transition.Update { component, view, transition in
let frame = component.size.centered(around: component._position ?? CGPoint())
if view.frame != frame {
transition.setFrame(view: view, frame: frame)
}
let opacity = component._opacity ?? 1.0
if view.alpha != opacity {
transition.setAlpha(view: view, alpha: opacity)
}
}
}

View File

@ -0,0 +1,798 @@
import Foundation
import UIKit
private func updateChildAnyComponent<EnvironmentType>(
id: _AnyChildComponent.Id,
component: AnyComponent<EnvironmentType>,
view: UIView,
availableSize: CGSize,
transition: Transition
) -> _UpdatedChildComponent {
let parentContext = _AnyCombinedComponentContext.current
if !parentContext.updateContext.updatedViews.insert(id).inserted {
preconditionFailure("Child component can only be processed once")
}
let context = view.context(component: component)
var isEnvironmentUpdated = false
var isStateUpdated = false
var isComponentUpdated = false
var availableSizeUpdated = false
if context.environment.calculateIsUpdated() {
context.environment._isUpdated = false
isEnvironmentUpdated = true
}
if context.erasedState.isUpdated {
context.erasedState.isUpdated = false
isStateUpdated = true
}
if context.erasedComponent != component {
isComponentUpdated = true
}
context.erasedComponent = component
if context.layoutResult.availableSize != availableSize {
context.layoutResult.availableSize = availableSize
availableSizeUpdated = true
}
let isUpdated = isEnvironmentUpdated || isStateUpdated || isComponentUpdated || availableSizeUpdated
if !isUpdated, let size = context.layoutResult.size {
return _UpdatedChildComponent(
id: id,
component: component,
view: view,
context: context,
size: size
)
} else {
let size = component._update(
view: view,
availableSize: availableSize,
transition: transition
)
context.layoutResult.size = size
return _UpdatedChildComponent(
id: id,
component: component,
view: view,
context: context,
size: size
)
}
}
public class _AnyChildComponent {
fileprivate enum Id: Hashable {
case direct(Int)
case mapped(Int, AnyHashable)
}
fileprivate var directId: Int {
return Int(bitPattern: Unmanaged.passUnretained(self).toOpaque())
}
}
public final class _ConcreteChildComponent<ComponentType: Component>: _AnyChildComponent {
fileprivate var id: Id {
return .direct(self.directId)
}
public func update(component: ComponentType, @EnvironmentBuilder environment: () -> Environment<ComponentType.EnvironmentType>, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent {
let parentContext = _AnyCombinedComponentContext.current
if !parentContext.updateContext.configuredViews.insert(self.id).inserted {
preconditionFailure("Child component can only be configured once")
}
var transition = transition
let view: ComponentType.View
if let current = parentContext.childViews[self.id] {
// TODO: Check if the type is the same
view = current.view as! ComponentType.View
} else {
view = component.makeView()
transition = .immediate
}
let context = view.context(component: component)
EnvironmentBuilder._environment = context.erasedEnvironment
let _ = environment()
EnvironmentBuilder._environment = nil
return updateChildAnyComponent(
id: self.id,
component: AnyComponent(component),
view: view,
availableSize: availableSize,
transition: transition
)
}
}
public extension _ConcreteChildComponent where ComponentType.EnvironmentType == Empty {
func update(component: ComponentType, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent {
return self.update(component: component, environment: {}, availableSize: availableSize, transition: transition)
}
}
public final class _UpdatedChildComponentGuide {
fileprivate let instance: _ChildComponentGuide
fileprivate init(instance: _ChildComponentGuide) {
self.instance = instance
}
}
public final class _ChildComponentGuide {
fileprivate var directId: Int {
return Int(bitPattern: Unmanaged.passUnretained(self).toOpaque())
}
fileprivate var id: _AnyChildComponent.Id {
return .direct(self.directId)
}
public func update(position: CGPoint, transition: Transition) -> _UpdatedChildComponentGuide {
let parentContext = _AnyCombinedComponentContext.current
let previousPosition = parentContext.guides[self.id]
if parentContext.updateContext.configuredGuides.updateValue(_AnyCombinedComponentContext.UpdateContext.ConfiguredGuide(previousPosition: previousPosition ?? position, position: position), forKey: self.id) != nil {
preconditionFailure("Child guide can only be configured once")
}
for disappearingView in parentContext.disappearingChildViews {
if disappearingView.guideId == self.id {
disappearingView.transitionWithGuide?(
stage: .update,
view: disappearingView.view,
guide: position,
transition: transition,
completion: disappearingView.completion
)
}
}
return _UpdatedChildComponentGuide(instance: self)
}
}
public final class _UpdatedChildComponent {
fileprivate let id: _AnyChildComponent.Id
fileprivate let component: _TypeErasedComponent
fileprivate let view: UIView
fileprivate let context: _TypeErasedComponentContext
public let size: CGSize
var _removed: Bool = false
var _position: CGPoint?
var _opacity: CGFloat?
fileprivate var transitionAppear: Transition.Appear?
fileprivate var transitionAppearWithGuide: (Transition.AppearWithGuide, _AnyChildComponent.Id)?
fileprivate var transitionDisappear: Transition.Disappear?
fileprivate var transitionDisappearWithGuide: (Transition.DisappearWithGuide, _AnyChildComponent.Id)?
fileprivate var transitionUpdate: Transition.Update?
fileprivate var gestures: [Gesture] = []
fileprivate init(
id: _AnyChildComponent.Id,
component: _TypeErasedComponent,
view: UIView,
context: _TypeErasedComponentContext,
size: CGSize
) {
self.id = id
self.component = component
self.view = view
self.context = context
self.size = size
}
@discardableResult public func appear(_ transition: Transition.Appear) -> _UpdatedChildComponent {
self.transitionAppear = transition
self.transitionAppearWithGuide = nil
return self
}
@discardableResult public func appear(_ transition: Transition.AppearWithGuide, guide: _UpdatedChildComponentGuide) -> _UpdatedChildComponent {
self.transitionAppear = nil
self.transitionAppearWithGuide = (transition, guide.instance.id)
return self
}
@discardableResult public func disappear(_ transition: Transition.Disappear) -> _UpdatedChildComponent {
self.transitionDisappear = transition
self.transitionDisappearWithGuide = nil
return self
}
@discardableResult public func disappear(_ transition: Transition.DisappearWithGuide, guide: _UpdatedChildComponentGuide) -> _UpdatedChildComponent {
self.transitionDisappear = nil
self.transitionDisappearWithGuide = (transition, guide.instance.id)
return self
}
@discardableResult public func update(_ transition: Transition.Update) -> _UpdatedChildComponent {
self.transitionUpdate = transition
return self
}
@discardableResult public func removed(_ removed: Bool) -> _UpdatedChildComponent {
self._removed = removed
return self
}
@discardableResult public func position(_ position: CGPoint) -> _UpdatedChildComponent {
self._position = position
return self
}
@discardableResult public func opacity(_ opacity: CGFloat) -> _UpdatedChildComponent {
self._opacity = opacity
return self
}
@discardableResult public func gesture(_ gesture: Gesture) -> _UpdatedChildComponent {
self.gestures.append(gesture)
return self
}
}
public final class _EnvironmentChildComponent<EnvironmentType>: _AnyChildComponent {
fileprivate var id: Id {
return .direct(self.directId)
}
func update(component: AnyComponent<EnvironmentType>, @EnvironmentBuilder environment: () -> Environment<EnvironmentType>, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent {
let parentContext = _AnyCombinedComponentContext.current
if !parentContext.updateContext.configuredViews.insert(self.id).inserted {
preconditionFailure("Child component can only be configured once")
}
var transition = transition
let view: UIView
if let current = parentContext.childViews[self.id] {
// Check if the type is the same
view = current.view
} else {
view = component._makeView()
transition = .immediate
}
EnvironmentBuilder._environment = view.context(component: component).erasedEnvironment
let _ = environment()
EnvironmentBuilder._environment = nil
return updateChildAnyComponent(
id: self.id,
component: component,
view: view,
availableSize: availableSize,
transition: transition
)
}
}
public extension _EnvironmentChildComponent where EnvironmentType == Empty {
func update(component: AnyComponent<EnvironmentType>, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent {
return self.update(component: component, environment: {}, availableSize: availableSize, transition: transition)
}
}
public extension _EnvironmentChildComponent {
func update<ComponentType: Component>(_ component: ComponentType, @EnvironmentBuilder environment: () -> Environment<EnvironmentType>, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent where ComponentType.EnvironmentType == EnvironmentType {
return self.update(component: AnyComponent(component), environment: environment, availableSize: availableSize, transition: transition)
}
func update<ComponentType: Component>(_ component: ComponentType, @EnvironmentBuilder environment: () -> Environment<EnvironmentType>, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent where ComponentType.EnvironmentType == EnvironmentType, EnvironmentType == Empty {
return self.update(component: AnyComponent(component), environment: {}, availableSize: availableSize, transition: transition)
}
}
public final class _EnvironmentChildComponentFromMap<EnvironmentType>: _AnyChildComponent {
private let id: Id
fileprivate init(id: Id) {
self.id = id
}
public func update(component: AnyComponent<EnvironmentType>, @EnvironmentBuilder environment: () -> Environment<EnvironmentType>, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent {
let parentContext = _AnyCombinedComponentContext.current
if !parentContext.updateContext.configuredViews.insert(self.id).inserted {
preconditionFailure("Child component can only be configured once")
}
var transition = transition
let view: UIView
if let current = parentContext.childViews[self.id] {
// Check if the type is the same
view = current.view
} else {
view = component._makeView()
transition = .immediate
}
EnvironmentBuilder._environment = view.context(component: component).erasedEnvironment
let _ = environment()
EnvironmentBuilder._environment = nil
return updateChildAnyComponent(
id: self.id,
component: component,
view: view,
availableSize: availableSize,
transition: transition
)
}
}
public extension _EnvironmentChildComponentFromMap where EnvironmentType == Empty {
func update(component: AnyComponent<EnvironmentType>, availableSize: CGSize, transition: Transition) -> _UpdatedChildComponent {
return self.update(component: component, environment: {}, availableSize: availableSize, transition: transition)
}
}
public final class _EnvironmentChildComponentMap<EnvironmentType, Key: Hashable> {
private var directId: Int {
return Int(bitPattern: Unmanaged.passUnretained(self).toOpaque())
}
public subscript(_ key: Key) -> _EnvironmentChildComponentFromMap<EnvironmentType> {
get {
return _EnvironmentChildComponentFromMap<EnvironmentType>(id: .mapped(self.directId, key))
}
}
}
public final class CombinedComponentContext<ComponentType: Component> {
fileprivate let escapeGuard = EscapeGuard()
private let context: ComponentContext<ComponentType>
public let view: UIView
public let component: ComponentType
public let availableSize: CGSize
public let transition: Transition
private let addImpl: (_ updatedComponent: _UpdatedChildComponent) -> Void
public var environment: Environment<ComponentType.EnvironmentType> {
return self.context.environment
}
public var state: ComponentType.State {
return self.context.state
}
fileprivate init(
context: ComponentContext<ComponentType>,
view: UIView,
component: ComponentType,
availableSize: CGSize,
transition: Transition,
add: @escaping (_ updatedComponent: _UpdatedChildComponent) -> Void
) {
self.context = context
self.view = view
self.component = component
self.availableSize = availableSize
self.transition = transition
self.addImpl = add
}
public func add(_ updatedComponent: _UpdatedChildComponent) {
self.addImpl(updatedComponent)
}
}
public protocol CombinedComponent: Component {
typealias Body = (CombinedComponentContext<Self>) -> CGSize
static var body: Body { get }
}
private class _AnyCombinedComponentContext {
class UpdateContext {
struct ConfiguredGuide {
var previousPosition: CGPoint
var position: CGPoint
}
var configuredViews: Set<_AnyChildComponent.Id> = Set()
var updatedViews: Set<_AnyChildComponent.Id> = Set()
var configuredGuides: [_AnyChildComponent.Id: ConfiguredGuide] = [:]
}
private static var _current: _AnyCombinedComponentContext?
static var current: _AnyCombinedComponentContext {
return self._current!
}
static func push(_ context: _AnyCombinedComponentContext) -> _AnyCombinedComponentContext? {
let previous = self._current
precondition(context._updateContext == nil)
context._updateContext = UpdateContext()
self._current = context
return previous
}
static func pop(_ context: _AnyCombinedComponentContext, stack: _AnyCombinedComponentContext?) {
precondition(context._updateContext != nil)
context._updateContext = nil
self._current = stack
}
class ChildView {
let view: UIView
var index: Int
var transition: Transition.Disappear?
var transitionWithGuide: (Transition.DisappearWithGuide, _AnyChildComponent.Id)?
var gestures: [UInt: UIGestureRecognizer] = [:]
init(view: UIView, index: Int) {
self.view = view
self.index = index
}
func updateGestures(_ gestures: [Gesture]) {
var validIds: [UInt] = []
for gesture in gestures {
validIds.append(gesture.id.id)
if let current = self.gestures[gesture.id.id] {
gesture.update(gesture: current)
} else {
let gestureInstance = gesture.create()
self.gestures[gesture.id.id] = gestureInstance
self.view.addGestureRecognizer(gestureInstance)
}
}
var removeIds: [UInt] = []
for id in self.gestures.keys {
if !validIds.contains(id) {
removeIds.append(id)
}
}
for id in removeIds {
if let gestureInstance = self.gestures.removeValue(forKey: id) {
self.view.removeGestureRecognizer(gestureInstance)
}
}
}
}
class DisappearingChildView {
let view: UIView
let guideId: _AnyChildComponent.Id?
let transition: Transition.Disappear?
let transitionWithGuide: Transition.DisappearWithGuide?
let completion: () -> Void
init(
view: UIView,
guideId: _AnyChildComponent.Id?,
transition: Transition.Disappear?,
transitionWithGuide: Transition.DisappearWithGuide?,
completion: @escaping () -> Void
) {
self.view = view
self.guideId = guideId
self.transition = transition
self.transitionWithGuide = transitionWithGuide
self.completion = completion
}
}
var childViews: [_AnyChildComponent.Id: ChildView] = [:]
var childViewIndices: [_AnyChildComponent.Id] = []
var guides: [_AnyChildComponent.Id: CGPoint] = [:]
var disappearingChildViews: [DisappearingChildView] = []
private var _updateContext: UpdateContext?
var updateContext: UpdateContext {
return self._updateContext!
}
}
private final class _CombinedComponentContext<ComponentType: CombinedComponent>: _AnyCombinedComponentContext {
var body: ComponentType.Body?
}
private var UIView_CombinedComponentContextKey: Int?
private extension UIView {
func getCombinedComponentContext<ComponentType: CombinedComponent>(_ type: ComponentType.Type) -> _CombinedComponentContext<ComponentType> {
if let context = objc_getAssociatedObject(self, &UIView_CombinedComponentContextKey) as? _CombinedComponentContext<ComponentType> {
return context
} else {
let context = _CombinedComponentContext<ComponentType>()
objc_setAssociatedObject(self, &UIView_CombinedComponentContextKey, context, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return context
}
}
}
public extension Transition {
final class Appear {
private let f: (_UpdatedChildComponent, UIView, Transition) -> Void
public init(_ f: @escaping (_UpdatedChildComponent, UIView, Transition) -> Void) {
self.f = f
}
public func callAsFunction(component: _UpdatedChildComponent, view: UIView, transition: Transition) {
self.f(component, view, transition)
}
}
final class AppearWithGuide {
private let f: (_UpdatedChildComponent, UIView, CGPoint, Transition) -> Void
public init(_ f: @escaping (_UpdatedChildComponent, UIView, CGPoint, Transition) -> Void) {
self.f = f
}
public func callAsFunction(component: _UpdatedChildComponent, view: UIView, guide: CGPoint, transition: Transition) {
self.f(component, view, guide, transition)
}
}
final class Disappear {
private let f: (UIView, Transition, @escaping () -> Void) -> Void
public init(_ f: @escaping (UIView, Transition, @escaping () -> Void) -> Void) {
self.f = f
}
public func callAsFunction(view: UIView, transition: Transition, completion: @escaping () -> Void) {
self.f(view, transition, completion)
}
}
final class DisappearWithGuide {
public enum Stage {
case begin
case update
}
private let f: (Stage, UIView, CGPoint, Transition, @escaping () -> Void) -> Void
public init(_ f: @escaping (Stage, UIView, CGPoint, Transition, @escaping () -> Void) -> Void
) {
self.f = f
}
public func callAsFunction(stage: Stage, view: UIView, guide: CGPoint, transition: Transition, completion: @escaping () -> Void) {
self.f(stage, view, guide, transition, completion)
}
}
final class Update {
private let f: (_UpdatedChildComponent, UIView, Transition) -> Void
public init(_ f: @escaping (_UpdatedChildComponent, UIView, Transition) -> Void) {
self.f = f
}
public func callAsFunction(component: _UpdatedChildComponent, view: UIView, transition: Transition) {
self.f(component, view, transition)
}
}
}
public extension CombinedComponent {
func makeView() -> UIView {
return UIView()
}
func update(view: View, availableSize: CGSize, transition: Transition) -> CGSize {
let context = view.getCombinedComponentContext(Self.self)
let storedBody: Body
if let current = context.body {
storedBody = current
} else {
storedBody = Self.body
context.body = storedBody
}
let viewContext = view.context(component: self)
var nextChildIndex = 0
var addedChildIds = Set<_AnyChildComponent.Id>()
let contextStack = _AnyCombinedComponentContext.push(context)
let escapeStatus: EscapeGuard.Status
let size: CGSize
do {
let bodyContext = CombinedComponentContext<Self>(
context: viewContext,
view: view,
component: self,
availableSize: availableSize,
transition: transition,
add: { updatedChild in
if !addedChildIds.insert(updatedChild.id).inserted {
preconditionFailure("Child component can only be added once")
}
let index = nextChildIndex
nextChildIndex += 1
if let previousView = context.childViews[updatedChild.id] {
precondition(updatedChild.view === previousView.view)
if index != previousView.index {
assert(index < previousView.index)
for i in index ..< previousView.index {
if let moveView = context.childViews[context.childViewIndices[i]] {
moveView.index += 1
}
}
context.childViewIndices.remove(at: previousView.index)
context.childViewIndices.insert(updatedChild.id, at: index)
previousView.index = index
view.insertSubview(previousView.view, at: index)
}
previousView.updateGestures(updatedChild.gestures)
previousView.transition = updatedChild.transitionDisappear
previousView.transitionWithGuide = updatedChild.transitionDisappearWithGuide
(updatedChild.transitionUpdate ?? Transition.Update.default)(component: updatedChild, view: updatedChild.view, transition: transition)
} else {
for i in index ..< context.childViewIndices.count {
if let moveView = context.childViews[context.childViewIndices[i]] {
moveView.index += 1
}
}
context.childViewIndices.insert(updatedChild.id, at: index)
let childView = _AnyCombinedComponentContext.ChildView(view: updatedChild.view, index: index)
context.childViews[updatedChild.id] = childView
childView.updateGestures(updatedChild.gestures)
childView.transition = updatedChild.transitionDisappear
childView.transitionWithGuide = updatedChild.transitionDisappearWithGuide
view.insertSubview(updatedChild.view, at: index)
updatedChild.view.frame = updatedChild.size.centered(around: updatedChild._position ?? CGPoint())
updatedChild.view.alpha = updatedChild._opacity ?? 1.0
updatedChild.view.context(typeErasedComponent: updatedChild.component).erasedState._updated = { [weak viewContext] transition in
guard let viewContext = viewContext else {
return
}
viewContext.state.updated(transition: transition)
}
if let transitionAppearWithGuide = updatedChild.transitionAppearWithGuide {
guard let guide = context.updateContext.configuredGuides[transitionAppearWithGuide.1] else {
preconditionFailure("Guide should be configured before using")
}
transitionAppearWithGuide.0(
component: updatedChild,
view: updatedChild.view,
guide: guide.previousPosition,
transition: transition
)
} else if let transitionAppear = updatedChild.transitionAppear {
transitionAppear(
component: updatedChild,
view: updatedChild.view,
transition: transition
)
}
}
}
)
escapeStatus = bodyContext.escapeGuard.status
size = storedBody(bodyContext)
}
assert(escapeStatus.isDeallocated, "Body context should not be stored for later use")
if nextChildIndex < context.childViewIndices.count {
for i in nextChildIndex ..< context.childViewIndices.count {
let id = context.childViewIndices[i]
if let childView = context.childViews.removeValue(forKey: id) {
let view = childView.view
let completion: () -> Void = { [weak context, weak view] in
view?.removeFromSuperview()
if let context = context {
for i in 0 ..< context.disappearingChildViews.count {
if context.disappearingChildViews[i].view === view {
context.disappearingChildViews.remove(at: i)
break
}
}
}
}
if let transitionWithGuide = childView.transitionWithGuide {
guard let guide = context.updateContext.configuredGuides[transitionWithGuide.1] else {
preconditionFailure("Guide should be configured before using")
}
context.disappearingChildViews.append(_AnyCombinedComponentContext.DisappearingChildView(
view: view,
guideId: transitionWithGuide.1,
transition: nil,
transitionWithGuide: transitionWithGuide.0,
completion: completion
))
view.isUserInteractionEnabled = false
transitionWithGuide.0(
stage: .begin,
view: view,
guide: guide.position,
transition: transition,
completion: completion
)
} else if let simpleTransition = childView.transition {
context.disappearingChildViews.append(_AnyCombinedComponentContext.DisappearingChildView(
view: view,
guideId: nil,
transition: simpleTransition,
transitionWithGuide: nil,
completion: completion
))
view.isUserInteractionEnabled = false
simpleTransition(
view: view,
transition: transition,
completion: completion
)
} else {
childView.view.removeFromSuperview()
}
}
}
context.childViewIndices.removeSubrange(nextChildIndex...)
}
if addedChildIds != context.updateContext.updatedViews {
preconditionFailure("Updated and added child lists do not match")
}
context.guides.removeAll()
for (id, guide) in context.updateContext.configuredGuides {
context.guides[id] = guide.position
}
_AnyCombinedComponentContext.pop(context, stack: contextStack)
return size
}
}
public extension CombinedComponent {
static func Child<Environment>(environment: Environment.Type) -> _EnvironmentChildComponent<Environment> {
return _EnvironmentChildComponent<Environment>()
}
static func ChildMap<Environment, Key: Hashable>(environment: Environment.Type, keyedBy keyType: Key.Type) -> _EnvironmentChildComponentMap<Environment, Key> {
return _EnvironmentChildComponentMap<Environment, Key>()
}
static func Child<ComponentType: Component>(_ type: ComponentType.Type) -> _ConcreteChildComponent<ComponentType> {
return _ConcreteChildComponent<ComponentType>()
}
static func Guide() -> _ChildComponentGuide {
return _ChildComponentGuide()
}
}

View File

@ -0,0 +1,203 @@
import Foundation
import UIKit
import ObjectiveC
public class ComponentLayoutResult {
var availableSize: CGSize?
var size: CGSize?
}
public protocol _TypeErasedComponentContext: AnyObject {
var erasedEnvironment: _Environment { get }
var erasedState: ComponentState { get }
var layoutResult: ComponentLayoutResult { get }
}
class AnyComponentContext<EnvironmentType>: _TypeErasedComponentContext {
var erasedComponent: AnyComponent<EnvironmentType> {
get {
preconditionFailure()
} set(value) {
preconditionFailure()
}
}
var erasedState: ComponentState {
preconditionFailure()
}
var erasedEnvironment: _Environment {
return self.environment
}
let layoutResult: ComponentLayoutResult
var environment: Environment<EnvironmentType>
init(environment: Environment<EnvironmentType>) {
self.layoutResult = ComponentLayoutResult()
self.environment = environment
}
}
class ComponentContext<ComponentType: Component>: AnyComponentContext<ComponentType.EnvironmentType> {
override var erasedComponent: AnyComponent<ComponentType.EnvironmentType> {
get {
return AnyComponent(self.component)
} set(value) {
self.component = value.wrapped as! ComponentType
}
}
var component: ComponentType
let state: ComponentType.State
override var erasedState: ComponentState {
return self.state
}
init(component: ComponentType, environment: Environment<ComponentType.EnvironmentType>, state: ComponentType.State) {
self.component = component
self.state = state
super.init(environment: environment)
}
}
private var UIView_TypeErasedComponentContextKey: Int?
extension UIView {
func context<EnvironmentType>(component: AnyComponent<EnvironmentType>) -> AnyComponentContext<EnvironmentType> {
return self.context(typeErasedComponent: component) as! AnyComponentContext<EnvironmentType>
}
func context<ComponentType: Component>(component: ComponentType) -> ComponentContext<ComponentType> {
return self.context(typeErasedComponent: component) as! ComponentContext<ComponentType>
}
func context(typeErasedComponent component: _TypeErasedComponent) -> _TypeErasedComponentContext{
if let context = objc_getAssociatedObject(self, &UIView_TypeErasedComponentContextKey) as? _TypeErasedComponentContext {
return context
} else {
let context = component._makeContext()
objc_setAssociatedObject(self, &UIView_TypeErasedComponentContextKey, context, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return context
}
}
}
public class ComponentState {
var _updated: ((Transition) -> Void)?
var isUpdated: Bool = false
public final func updated(transition: Transition = .immediate) {
self.isUpdated = true
self._updated?(transition)
}
}
public final class EmptyComponentState: ComponentState {
}
public protocol _TypeErasedComponent {
func _makeView() -> UIView
func _makeContext() -> _TypeErasedComponentContext
func _update(view: UIView, availableSize: CGSize, transition: Transition) -> CGSize
func _isEqual(to other: _TypeErasedComponent) -> Bool
}
public protocol Component: _TypeErasedComponent, Equatable {
associatedtype EnvironmentType = Empty
associatedtype View: UIView = UIView
associatedtype State: ComponentState = EmptyComponentState
func makeView() -> View
func makeState() -> State
func update(view: View, availableSize: CGSize, transition: Transition) -> CGSize
}
public extension Component {
func _makeView() -> UIView {
return self.makeView()
}
func _makeContext() -> _TypeErasedComponentContext {
return ComponentContext<Self>(component: self, environment: Environment<EnvironmentType>(), state: self.makeState())
}
func _update(view: UIView, availableSize: CGSize, transition: Transition) -> CGSize {
return self.update(view: view as! Self.View, availableSize: availableSize, transition: transition)
}
func _isEqual(to other: _TypeErasedComponent) -> Bool {
if let other = other as? Self {
return self == other
} else {
return false
}
}
}
public extension Component where Self.View == UIView {
func makeView() -> UIView {
return UIView()
}
}
public extension Component where Self.State == EmptyComponentState {
func makeState() -> State {
return EmptyComponentState()
}
}
public class ComponentGesture {
public static func tap(action: @escaping() -> Void) -> ComponentGesture {
preconditionFailure()
}
}
public class AnyComponent<EnvironmentType>: _TypeErasedComponent, Equatable {
fileprivate let wrapped: _TypeErasedComponent
public init<ComponentType: Component>(_ component: ComponentType) where ComponentType.EnvironmentType == EnvironmentType {
self.wrapped = component
}
public static func ==(lhs: AnyComponent<EnvironmentType>, rhs: AnyComponent<EnvironmentType>) -> Bool {
return lhs.wrapped._isEqual(to: rhs.wrapped)
}
public func _makeView() -> UIView {
return self.wrapped._makeView()
}
public func _makeContext() -> _TypeErasedComponentContext {
return self.wrapped._makeContext()
}
public func _update(view: UIView, availableSize: CGSize, transition: Transition) -> CGSize {
return self.wrapped._update(view: view, availableSize: availableSize, transition: transition)
}
public func _isEqual(to other: _TypeErasedComponent) -> Bool {
return self.wrapped._isEqual(to: other)
}
}
public final class AnyComponentWithIdentity<Environment>: Equatable {
public let id: AnyHashable
public let component: AnyComponent<Environment>
public init<IdType: Hashable>(id: IdType, component: AnyComponent<Environment>) {
self.id = AnyHashable(id)
self.component = component
}
public static func == (lhs: AnyComponentWithIdentity<Environment>, rhs: AnyComponentWithIdentity<Environment>) -> Bool {
if lhs.id != rhs.id {
return false
}
if lhs.component != rhs.component {
return false
}
return true
}
}

View File

@ -0,0 +1,219 @@
import Foundation
import UIKit
public final class Empty: Equatable {
static let shared: Empty = Empty()
public static func ==(lhs: Empty, rhs: Empty) -> Bool {
return true
}
}
public class _Environment {
fileprivate var data: [Int: _EnvironmentValue] = [:]
var _isUpdated: Bool = false
func calculateIsUpdated() -> Bool {
if self._isUpdated {
return true
}
for (_, item) in self.data {
if let parentEnvironment = item.parentEnvironment, parentEnvironment.calculateIsUpdated() {
return true
}
}
return false
}
fileprivate func set<T: Equatable>(index: Int, value: EnvironmentValue<T>) {
if let current = self.data[index] {
self.data[index] = value
if current as! EnvironmentValue<T> != value {
self._isUpdated = true
}
} else {
self.data[index] = value
self._isUpdated = true
}
}
}
private enum EnvironmentValueStorage<T> {
case direct(T)
case reference(_Environment, Int)
}
public class _EnvironmentValue {
fileprivate let parentEnvironment: _Environment?
fileprivate init(parentEnvironment: _Environment?) {
self.parentEnvironment = parentEnvironment
}
}
@dynamicMemberLookup
public final class EnvironmentValue<T: Equatable>: _EnvironmentValue, Equatable {
private var storage: EnvironmentValueStorage<T>
fileprivate var value: T {
switch self.storage {
case let .direct(value):
return value
case let .reference(environment, index):
return (environment.data[index] as! EnvironmentValue<T>).value
}
}
fileprivate init(_ value: T) {
self.storage = .direct(value)
super.init(parentEnvironment: nil)
}
fileprivate init(environment: _Environment, index: Int) {
self.storage = .reference(environment, index)
super.init(parentEnvironment: environment)
}
public static func ==(lhs: EnvironmentValue<T>, rhs: EnvironmentValue<T>) -> Bool {
if lhs === rhs {
return true
}
// TODO: follow the reference chain for faster equality checking
return lhs.value == rhs.value
}
public subscript<V>(dynamicMember keyPath: KeyPath<T, V>) -> V {
return self.value[keyPath: keyPath]
}
}
public class Environment<T>: _Environment {
private let file: StaticString
private let line: Int
public init(_ file: StaticString = #file, _ line: Int = #line) {
self.file = file
self.line = line
}
}
public extension Environment where T == Empty {
static let value: Environment<Empty> = {
let result = Environment<Empty>()
result.set(index: 0, value: EnvironmentValue(Empty()))
return result
}()
}
public extension Environment {
subscript(_ t1: T.Type) -> EnvironmentValue<T> where T: Equatable {
return EnvironmentValue(environment: self, index: 0)
}
subscript<T1, T2>(_ t1: T1.Type) -> EnvironmentValue<T1> where T == (T1, T2), T1: Equatable, T2: Equatable {
return EnvironmentValue(environment: self, index: 0)
}
subscript<T1, T2>(_ t2: T2.Type) -> EnvironmentValue<T2> where T == (T1, T2), T1: Equatable, T2: Equatable {
return EnvironmentValue(environment: self, index: 1)
}
subscript<T1, T2, T3>(_ t1: T1.Type) -> EnvironmentValue<T1> where T == (T1, T2, T3), T1: Equatable, T2: Equatable, T3: Equatable {
return EnvironmentValue(environment: self, index: 0)
}
subscript<T1, T2, T3>(_ t2: T2.Type) -> EnvironmentValue<T2> where T == (T1, T2, T3), T1: Equatable, T2: Equatable, T3: Equatable {
return EnvironmentValue(environment: self, index: 1)
}
subscript<T1, T2, T3>(_ t3: T3.Type) -> EnvironmentValue<T3> where T == (T1, T2, T3), T1: Equatable, T2: Equatable, T3: Equatable {
return EnvironmentValue(environment: self, index: 2)
}
subscript<T1, T2, T3, T4>(_ t1: T1.Type) -> EnvironmentValue<T1> where T == (T1, T2, T3, T4), T1: Equatable, T2: Equatable, T3: Equatable, T4: Equatable {
return EnvironmentValue(environment: self, index: 0)
}
subscript<T1, T2, T3, T4>(_ t2: T2.Type) -> EnvironmentValue<T2> where T == (T1, T2, T3, T4), T1: Equatable, T2: Equatable, T3: Equatable, T4: Equatable {
return EnvironmentValue(environment: self, index: 1)
}
subscript<T1, T2, T3, T4>(_ t3: T3.Type) -> EnvironmentValue<T3> where T == (T1, T2, T3, T4), T1: Equatable, T2: Equatable, T3: Equatable, T4: Equatable {
return EnvironmentValue(environment: self, index: 2)
}
subscript<T1, T2, T3, T4>(_ t4: T4.Type) -> EnvironmentValue<T4> where T == (T1, T2, T3, T4), T1: Equatable, T2: Equatable, T3: Equatable, T4: Equatable {
return EnvironmentValue(environment: self, index: 3)
}
}
@resultBuilder
public struct EnvironmentBuilder {
static var _environment: _Environment?
private static func current<T>(_ type: T.Type) -> Environment<T> {
return self._environment as! Environment<T>
}
public struct Partial<T: Equatable> {
fileprivate var value: EnvironmentValue<T>
}
public static func buildBlock() -> Environment<Empty> {
let result = self.current(Empty.self)
result.set(index: 0, value: EnvironmentValue(Empty.shared))
return result
}
public static func buildExpression<T: Equatable>(_ expression: T) -> Partial<T> {
return Partial<T>(value: EnvironmentValue(expression))
}
public static func buildExpression<T: Equatable>(_ expression: EnvironmentValue<T>) -> Partial<T> {
return Partial<T>(value: expression)
}
public static func buildBlock<T1: Equatable>(_ t1: Partial<T1>) -> Environment<T1> {
let result = self.current(T1.self)
result.set(index: 0, value: t1.value)
return result
}
public static func buildBlock<T1: Equatable, T2: Equatable>(_ t1: Partial<T1>, _ t2: Partial<T2>) -> Environment<(T1, T2)> {
let result = self.current((T1, T2).self)
result.set(index: 0, value: t1.value)
result.set(index: 1, value: t2.value)
return result
}
public static func buildBlock<T1: Equatable, T2: Equatable, T3: Equatable>(_ t1: Partial<T1>, _ t2: Partial<T2>, _ t3: Partial<T3>) -> Environment<(T1, T2, T3)> {
let result = self.current((T1, T2, T3).self)
result.set(index: 0, value: t1.value)
result.set(index: 1, value: t2.value)
result.set(index: 2, value: t3.value)
return result
}
public static func buildBlock<T1: Equatable, T2: Equatable, T3: Equatable, T4: Equatable>(_ t1: Partial<T1>, _ t2: Partial<T2>, _ t3: Partial<T3>, _ t4: Partial<T4>) -> Environment<(T1, T2, T3, T4)> {
let result = self.current((T1, T2, T3, T4).self)
result.set(index: 0, value: t1.value)
result.set(index: 1, value: t2.value)
result.set(index: 2, value: t3.value)
result.set(index: 3, value: t4.value)
return result
}
}
@propertyWrapper
public struct ZeroEquatable<T>: Equatable {
public var wrappedValue: T
public init(_ wrappedValue: T) {
self.wrappedValue = wrappedValue
}
public static func ==(lhs: ZeroEquatable<T>, rhs: ZeroEquatable<T>) -> Bool {
return true
}
}

View File

@ -0,0 +1,270 @@
import Foundation
import UIKit
private extension UIView {
static var animationDurationFactor: Double {
return 1.0
}
}
@objc private class CALayerAnimationDelegate: NSObject, CAAnimationDelegate {
private let keyPath: String?
var completion: ((Bool) -> Void)?
init(animation: CAAnimation, completion: ((Bool) -> Void)?) {
if let animation = animation as? CABasicAnimation {
self.keyPath = animation.keyPath
} else {
self.keyPath = nil
}
self.completion = completion
super.init()
}
@objc func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if let anim = anim as? CABasicAnimation {
if anim.keyPath != self.keyPath {
return
}
}
if let completion = self.completion {
completion(flag)
}
}
}
private func makeSpringAnimation(keyPath: String) -> CASpringAnimation {
let springAnimation = CASpringAnimation(keyPath: keyPath)
springAnimation.mass = 3.0;
springAnimation.stiffness = 1000.0
springAnimation.damping = 500.0
springAnimation.duration = 0.5
springAnimation.timingFunction = CAMediaTimingFunction(name: .linear)
return springAnimation
}
private extension CALayer {
func makeAnimation(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double, curve: Transition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) -> CAAnimation {
switch curve {
case .spring:
let animation = makeSpringAnimation(keyPath: keyPath)
animation.fromValue = from
animation.toValue = to
animation.isRemovedOnCompletion = removeOnCompletion
animation.fillMode = .forwards
if let completion = completion {
animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion)
}
let k = Float(UIView.animationDurationFactor)
var speed: Float = 1.0
if k != 0 && k != 1 {
speed = Float(1.0) / k
}
animation.speed = speed * Float(animation.duration / duration)
animation.isAdditive = additive
if !delay.isZero {
animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor
animation.fillMode = .both
}
return animation
default:
let k = Float(UIView.animationDurationFactor)
var speed: Float = 1.0
if k != 0 && k != 1 {
speed = Float(1.0) / k
}
let animation = CABasicAnimation(keyPath: keyPath)
animation.fromValue = from
animation.toValue = to
animation.duration = duration
animation.timingFunction = curve.asTimingFunction()
animation.isRemovedOnCompletion = removeOnCompletion
animation.fillMode = .forwards
animation.speed = speed
animation.isAdditive = additive
if let completion = completion {
animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion)
}
if !delay.isZero {
animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor
animation.fillMode = .both
}
return animation
}
}
func animate(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double, curve: Transition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) {
let animation = self.makeAnimation(from: from, to: to, keyPath: keyPath, duration: duration, delay: delay, curve: curve, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion)
self.add(animation, forKey: additive ? nil : keyPath)
}
}
private extension Transition.Animation.Curve {
func asTimingFunction() -> CAMediaTimingFunction {
switch self {
case .easeInOut:
return CAMediaTimingFunction(name: .easeInEaseOut)
case .spring:
preconditionFailure()
}
}
}
public struct Transition {
public enum Animation {
public enum Curve {
case easeInOut
case spring
}
case none
case curve(duration: Double, curve: Curve)
}
public var animation: Animation
private var _userData: [Any] = []
public func userData<T>(_ type: T.Type) -> T? {
for item in self._userData {
if let item = item as? T {
return item
}
}
return nil
}
public func withUserData(_ userData: Any) -> Transition {
var result = self
result._userData.append(userData)
return result
}
public static var immediate: Transition = Transition(animation: .none)
public static func easeInOut(duration: Double) -> Transition {
return Transition(animation: .curve(duration: duration, curve: .easeInOut))
}
public init(animation: Animation) {
self.animation = animation
}
public func setFrame(view: UIView, frame: CGRect, completion: ((Bool) -> Void)? = nil) {
if view.frame == frame {
completion?(true)
return
}
switch self.animation {
case .none:
view.frame = frame
completion?(true)
case .curve:
let previousPosition = view.center
let previousBounds = view.bounds
view.frame = frame
self.animatePosition(view: view, from: previousPosition, to: view.center, completion: completion)
self.animateBounds(view: view, from: previousBounds, to: view.bounds)
}
}
public func setAlpha(view: UIView, alpha: CGFloat, completion: ((Bool) -> Void)? = nil) {
if view.alpha == alpha {
completion?(true)
return
}
switch self.animation {
case .none:
view.alpha = alpha
completion?(true)
case .curve:
let previousAlpha = view.alpha
view.alpha = alpha
self.animateAlpha(view: view, from: previousAlpha, to: alpha, completion: completion)
}
}
public func animateScale(view: UIView, from fromValue: CGFloat, to toValue: CGFloat, additive: Bool = false, completion: ((Bool) -> Void)? = nil) {
switch self.animation {
case .none:
completion?(true)
case let .curve(duration, curve):
view.layer.animate(
from: fromValue as NSNumber,
to: toValue as NSNumber,
keyPath: "transform.scale",
duration: duration,
delay: 0.0,
curve: curve,
removeOnCompletion: true,
additive: additive,
completion: completion
)
}
}
public func animateAlpha(view: UIView, from fromValue: CGFloat, to toValue: CGFloat, additive: Bool = false, completion: ((Bool) -> Void)? = nil) {
switch self.animation {
case .none:
completion?(true)
case let .curve(duration, curve):
view.layer.animate(
from: fromValue as NSNumber,
to: toValue as NSNumber,
keyPath: "opacity",
duration: duration,
delay: 0.0,
curve: curve,
removeOnCompletion: true,
additive: additive,
completion: completion
)
}
}
public func animatePosition(view: UIView, from fromValue: CGPoint, to toValue: CGPoint, additive: Bool = false, completion: ((Bool) -> Void)? = nil) {
switch self.animation {
case .none:
completion?(true)
case let .curve(duration, curve):
view.layer.animate(
from: NSValue(cgPoint: fromValue),
to: NSValue(cgPoint: toValue),
keyPath: "position",
duration: duration,
delay: 0.0,
curve: curve,
removeOnCompletion: true,
additive: additive,
completion: completion
)
}
}
public func animateBounds(view: UIView, from fromValue: CGRect, to toValue: CGRect, additive: Bool = false, completion: ((Bool) -> Void)? = nil) {
switch self.animation {
case .none:
break
case let .curve(duration, curve):
view.layer.animate(
from: NSValue(cgRect: fromValue),
to: NSValue(cgRect: toValue),
keyPath: "bounds",
duration: duration,
delay: 0.0,
curve: curve,
removeOnCompletion: true,
additive: additive,
completion: completion
)
}
}
}

View File

@ -0,0 +1,68 @@
import Foundation
import UIKit
final class Button: CombinedComponent, Equatable {
let content: AnyComponent<Empty>
let insets: UIEdgeInsets
let action: () -> Void
init(
content: AnyComponent<Empty>,
insets: UIEdgeInsets,
action: @escaping () -> Void
) {
self.content = content
self.insets = insets
self.action = action
}
static func ==(lhs: Button, rhs: Button) -> Bool {
if lhs.content != rhs.content {
return false
}
if lhs.insets != rhs.insets {
return false
}
return true
}
final class State: ComponentState {
var isHighlighted = false
override init() {
super.init()
}
}
func makeState() -> State {
return State()
}
static var body: Body {
let content = Child(environment: Empty.self)
return { context in
let content = content.update(
component: context.component.content,
availableSize: CGSize(width: context.availableSize.width, height: 44.0), transition: context.transition
)
let size = CGSize(width: content.size.width + context.component.insets.left + context.component.insets.right, height: content.size.height + context.component.insets.top + context.component.insets.bottom)
let component = context.component
context.add(content
.position(CGPoint(x: size.width / 2.0, y: size.height / 2.0))
.opacity(context.state.isHighlighted ? 0.2 : 1.0)
.update(Transition.Update { component, view, transition in
view.frame = component.size.centered(around: component._position ?? CGPoint())
})
.gesture(.tap {
component.action()
})
)
return size
}
}
}

View File

@ -0,0 +1,48 @@
import Foundation
import UIKit
public final class List<ChildEnvironment: Equatable>: CombinedComponent {
public typealias EnvironmentType = ChildEnvironment
private let items: [AnyComponentWithIdentity<ChildEnvironment>]
private let appear: Transition.Appear
public init(_ items: [AnyComponentWithIdentity<ChildEnvironment>], appear: Transition.Appear = .default()) {
self.items = items
self.appear = appear
}
public static func ==(lhs: List<ChildEnvironment>, rhs: List<ChildEnvironment>) -> Bool {
if lhs.items != rhs.items {
return false
}
return true
}
public static var body: Body {
let children = ChildMap(environment: ChildEnvironment.self, keyedBy: AnyHashable.self)
return { context in
let updatedChildren = context.component.items.map { item in
return children[item.id].update(
component: item.component, environment: {
context.environment[ChildEnvironment.self]
},
availableSize: context.availableSize,
transition: context.transition
)
}
var nextOrigin: CGFloat = 0.0
for child in updatedChildren {
context.add(child
.position(CGPoint(x: child.size.width / 2.0, y: nextOrigin + child.size.height / 2.0))
.appear(context.component.appear)
)
nextOrigin += child.size.height
}
return context.availableSize
}
}
}

View File

@ -0,0 +1,41 @@
import Foundation
import UIKit
public final class Rectangle: Component {
private let color: UIColor
private let width: CGFloat?
private let height: CGFloat?
public init(color: UIColor, width: CGFloat? = nil, height: CGFloat? = nil) {
self.color = color
self.width = width
self.height = height
}
public static func ==(lhs: Rectangle, rhs: Rectangle) -> Bool {
if !lhs.color.isEqual(rhs.color) {
return false
}
if lhs.width != rhs.width {
return false
}
if lhs.height != rhs.height {
return false
}
return true
}
public func update(view: UIView, availableSize: CGSize, transition: Transition) -> CGSize {
var size = availableSize
if let width = self.width {
size.width = min(size.width, width)
}
if let height = self.height {
size.height = min(size.height, height)
}
view.backgroundColor = self.color
return size
}
}

View File

@ -0,0 +1,101 @@
import Foundation
import UIKit
public final class Text: Component {
private final class MeasureState: Equatable {
let attributedText: NSAttributedString
let availableSize: CGSize
let size: CGSize
init(attributedText: NSAttributedString, availableSize: CGSize, size: CGSize) {
self.attributedText = attributedText
self.availableSize = availableSize
self.size = size
}
static func ==(lhs: MeasureState, rhs: MeasureState) -> Bool {
if !lhs.attributedText.isEqual(rhs.attributedText) {
return false
}
if lhs.availableSize != rhs.availableSize {
return false
}
if lhs.size != rhs.size {
return false
}
return true
}
}
public final class View: UIView {
private var measureState: MeasureState?
func update(component: Text, availableSize: CGSize) -> CGSize {
let attributedText = NSAttributedString(string: component.text, attributes: [
NSAttributedString.Key.font: component.font,
NSAttributedString.Key.foregroundColor: component.color
])
if let measureState = self.measureState {
if measureState.attributedText.isEqual(to: attributedText) && measureState.availableSize == availableSize {
return measureState.size
}
}
var boundingRect = attributedText.boundingRect(with: availableSize, options: .usesLineFragmentOrigin, context: nil)
boundingRect.size.width = ceil(boundingRect.size.width)
boundingRect.size.height = ceil(boundingRect.size.height)
let measureState = MeasureState(attributedText: attributedText, availableSize: availableSize, size: boundingRect.size)
if #available(iOS 10.0, *) {
let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: measureState.size))
let image = renderer.image { context in
UIGraphicsPushContext(context.cgContext)
measureState.attributedText.draw(at: CGPoint())
UIGraphicsPopContext()
}
self.layer.contents = image.cgImage
} else {
UIGraphicsBeginImageContextWithOptions(measureState.size, false, 0.0)
measureState.attributedText.draw(at: CGPoint())
self.layer.contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage
UIGraphicsEndImageContext()
}
self.measureState = measureState
return boundingRect.size
}
}
public let text: String
public let font: UIFont
public let color: UIColor
public init(text: String, font: UIFont, color: UIColor) {
self.text = text
self.font = font
self.color = color
}
public static func ==(lhs: Text, rhs: Text) -> Bool {
if lhs.text != rhs.text {
return false
}
if !lhs.font.isEqual(rhs.font) {
return false
}
if !lhs.color.isEqual(rhs.color) {
return false
}
return true
}
public func makeView() -> View {
return View()
}
public func update(view: View, availableSize: CGSize, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize)
}
}

View File

@ -0,0 +1,29 @@
import Foundation
import UIKit
public class Gesture {
class Id {
private var _id: UInt = 0
public var id: UInt {
return self._id
}
init() {
self._id = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque())
}
}
let id: Id
init(id: Id) {
self.id = id
}
func create() -> UIGestureRecognizer {
preconditionFailure()
}
func update(gesture: UIGestureRecognizer) {
preconditionFailure()
}
}

View File

@ -0,0 +1,59 @@
import Foundation
import UIKit
public extension Gesture {
enum PanGestureState {
case began
case updated(offset: CGPoint)
case ended
}
private final class PanGesture: Gesture {
private class Impl: UIPanGestureRecognizer {
var action: (PanGestureState) -> Void
init(action: @escaping (PanGestureState) -> Void) {
self.action = action
super.init(target: nil, action: nil)
self.addTarget(self, action: #selector(self.onAction))
}
@objc private func onAction() {
switch self.state {
case .began:
self.action(.began)
case .ended, .cancelled:
self.action(.ended)
case .changed:
let offset = self.translation(in: self.view)
self.action(.updated(offset: offset))
default:
break
}
}
}
static let id = Id()
private let action: (PanGestureState) -> Void
init(action: @escaping (PanGestureState) -> Void) {
self.action = action
super.init(id: Self.id)
}
override func create() -> UIGestureRecognizer {
return Impl(action: self.action)
}
override func update(gesture: UIGestureRecognizer) {
(gesture as! Impl).action = action
}
}
static func pan(_ action: @escaping (PanGestureState) -> Void) -> Gesture {
return PanGesture(action: action)
}
}

View File

@ -0,0 +1,43 @@
import Foundation
import UIKit
public extension Gesture {
private final class TapGesture: Gesture {
private class Impl: UITapGestureRecognizer {
var action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
super.init(target: nil, action: nil)
self.addTarget(self, action: #selector(self.onAction))
}
@objc private func onAction() {
self.action()
}
}
static let id = Id()
private let action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
super.init(id: Self.id)
}
override func create() -> UIGestureRecognizer {
return Impl(action: self.action)
}
override func update(gesture: UIGestureRecognizer) {
(gesture as! Impl).action = action
}
}
static func tap(_ action: @escaping () -> Void) -> Gesture {
return TapGesture(action: action)
}
}

View File

@ -0,0 +1,69 @@
import Foundation
import UIKit
public final class ComponentHostView<EnvironmentType>: UIView {
private var componentView: UIView?
private(set) var isUpdating: Bool = false
public init() {
super.init(frame: CGRect())
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func update(transition: Transition, component: AnyComponent<EnvironmentType>, @EnvironmentBuilder environment: () -> Environment<EnvironmentType>, containerSize: CGSize) -> CGSize {
self._update(transition: transition, component: component, maybeEnvironment: environment, updateEnvironment: true, containerSize: containerSize)
}
private func _update(transition: Transition, component: AnyComponent<EnvironmentType>, maybeEnvironment: () -> Environment<EnvironmentType>, updateEnvironment: Bool, containerSize: CGSize) -> CGSize {
precondition(!self.isUpdating)
self.isUpdating = true
precondition(containerSize.width.isFinite)
precondition(containerSize.width < .greatestFiniteMagnitude)
precondition(containerSize.height.isFinite)
precondition(containerSize.height < .greatestFiniteMagnitude)
let componentView: UIView
if let current = self.componentView {
componentView = current
} else {
componentView = component._makeView()
self.componentView = componentView
self.addSubview(componentView)
}
let context = componentView.context(component: component)
let componentState: ComponentState = context.erasedState
if updateEnvironment {
EnvironmentBuilder._environment = context.erasedEnvironment
let _ = maybeEnvironment()
EnvironmentBuilder._environment = nil
}
componentState._updated = { [weak self] transition in
guard let strongSelf = self else {
return
}
let _ = strongSelf._update(transition: transition, component: component, maybeEnvironment: {
preconditionFailure()
} as () -> Environment<EnvironmentType>, updateEnvironment: false, containerSize: containerSize)
}
let updatedSize = component._update(view: componentView, availableSize: containerSize, transition: transition)
transition.setFrame(view: componentView, frame: CGRect(origin: CGPoint(), size: updatedSize))
self.isUpdating = false
return updatedSize
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
return result
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import UIKit
public struct NavigationLayout: Equatable {
public var statusBarHeight: CGFloat
public var inputHeight: CGFloat
public var bottomNavigationHeight: CGFloat
}

View File

@ -0,0 +1,134 @@
import Foundation
import UIKit
public final class RootHostView<EnvironmentType: Equatable>: UIViewController {
private let content: AnyComponent<(NavigationLayout, EnvironmentType)>
private var keyboardWillChangeFrameObserver: NSObjectProtocol?
private var inputHeight: CGFloat = 0.0
private let environment: Environment<EnvironmentType>
private var componentView: ComponentHostView<(NavigationLayout, EnvironmentType)>
private var scheduledTransition: Transition?
public init(
content: AnyComponent<(NavigationLayout, EnvironmentType)>,
@EnvironmentBuilder environment: () -> Environment<EnvironmentType>
) {
self.content = content
self.environment = Environment<EnvironmentType>()
self.componentView = ComponentHostView<(NavigationLayout, EnvironmentType)>()
EnvironmentBuilder._environment = self.environment
let _ = environment()
EnvironmentBuilder._environment = nil
super.init(nibName: nil, bundle: nil)
NotificationCenter.default.addObserver(forName: UIApplication.keyboardWillChangeFrameNotification, object: nil, queue: nil, using: { [weak self] notification in
guard let strongSelf = self else {
return
}
guard let keyboardFrame = notification.userInfo?[UIApplication.keyboardFrameEndUserInfoKey] as? CGRect else {
return
}
var duration: Double = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.0
if duration > Double.ulpOfOne {
duration = 0.5
}
let curve: UInt = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
let transition: Transition
if curve == 7 {
transition = Transition(animation: .curve(duration: duration, curve: .spring))
} else {
transition = Transition(animation: .curve(duration: duration, curve: .easeInOut))
}
strongSelf.updateKeyboardLayout(keyboardFrame: keyboardFrame, transition: transition)
})
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.keyboardWillChangeFrameObserver.flatMap(NotificationCenter.default.removeObserver)
}
private func updateKeyboardLayout(keyboardFrame: CGRect, transition: Transition) {
self.inputHeight = max(0.0, self.view.bounds.height - keyboardFrame.minY)
if self.componentView.isUpdating || true {
if let _ = self.scheduledTransition {
if case .curve = transition.animation {
self.scheduledTransition = transition
}
} else {
self.scheduledTransition = transition
}
self.view.setNeedsLayout()
} else {
self.updateComponent(size: self.view.bounds.size, transition: transition)
}
}
private func updateComponent(size: CGSize, transition: Transition) {
self.environment._isUpdated = false
transition.setFrame(view: self.componentView, frame: CGRect(origin: CGPoint(), size: size))
self.componentView.update(
transition: transition,
component: self.content,
environment: {
NavigationLayout(
statusBarHeight: size.width > size.height ? 0.0 : 40.0,
inputHeight: self.inputHeight,
bottomNavigationHeight: 22.0
)
self.environment[EnvironmentType.self]
},
containerSize: size
)
}
public func updateEnvironment(@EnvironmentBuilder environment: () -> Environment<EnvironmentType>) {
EnvironmentBuilder._environment = self.environment
let _ = environment()
EnvironmentBuilder._environment = nil
if self.environment.calculateIsUpdated() {
if !self.view.bounds.size.width.isZero {
self.updateComponent(size: self.view.bounds.size, transition: .immediate)
}
}
}
override public func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.componentView)
if !self.view.bounds.size.width.isZero {
self.updateComponent(size: self.view.bounds.size, transition: .immediate)
}
}
override public func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let scheduledTransition = self.scheduledTransition {
self.scheduledTransition = nil
self.updateComponent(size: self.view.bounds.size, transition: scheduledTransition)
}
}
override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
self.updateComponent(size: size, transition: coordinator.isAnimated ? .easeInOut(duration: 0.3) : .immediate)
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import UIKit
extension UIColor {
convenience init(rgb: UInt32) {
self.init(red: CGFloat((rgb >> 16) & 0xff) / 255.0, green: CGFloat((rgb >> 8) & 0xff) / 255.0, blue: CGFloat(rgb & 0xff) / 255.0, alpha: 1.0)
}
}

View File

@ -0,0 +1,9 @@
import Foundation
public func Condition<R>(_ f: @autoclosure () -> Bool, _ pass: () -> R) -> R? {
if f() {
return pass()
} else {
return nil
}
}

View File

@ -0,0 +1,13 @@
import Foundation
final class EscapeGuard {
final class Status {
fileprivate(set) var isDeallocated: Bool = false
}
let status = Status()
deinit {
self.status.isDeallocated = true
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import UIKit
public extension UIEdgeInsets {
init(_ value: CGFloat) {
self.init(top: value, left: value, bottom: value, right: value)
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import UIKit
public extension CGRect {
var center: CGPoint {
return CGPoint(x: self.midX, y: self.midY)
}
}

View File

@ -0,0 +1,28 @@
import Foundation
import UIKit
public extension CGSize {
func centered(in rect: CGRect) -> CGRect {
return CGRect(origin: CGPoint(x: rect.minX + floor((rect.width - self.width) / 2.0), y: rect.minY + floor((rect.height - self.height) / 2.0)), size: self)
}
func centered(around position: CGPoint) -> CGRect {
return CGRect(origin: CGPoint(x: position.x - self.width / 2.0, y: position.y - self.height / 2.0), size: self)
}
func leftCentered(in rect: CGRect) -> CGRect {
return CGRect(origin: CGPoint(x: rect.minX, y: rect.minY + floor((rect.height - self.height) / 2.0)), size: self)
}
func rightCentered(in rect: CGRect) -> CGRect {
return CGRect(origin: CGPoint(x: rect.maxX - self.width, y: rect.minY + floor((rect.height - self.height) / 2.0)), size: self)
}
func topCentered(in rect: CGRect) -> CGRect {
return CGRect(origin: CGPoint(x: rect.minX + floor((rect.width - self.width) / 2.0), y: 0.0), size: self)
}
func bottomCentered(in rect: CGRect) -> CGRect {
return CGRect(origin: CGPoint(x: rect.minX + floor((rect.width - self.width) / 2.0), y: rect.maxY - self.height), size: self)
}
}

View File

@ -478,5 +478,35 @@ public extension TelegramEngine {
public func updatePeerDescription(peerId: PeerId, description: String?) -> Signal<Void, UpdatePeerDescriptionError> {
return _internal_updatePeerDescription(account: self.account, peerId: peerId, description: description)
}
public func getNextUnreadChannel(peerId: PeerId) -> Signal<EnginePeer?, NoError> {
return self.account.postbox.transaction { transaction -> EnginePeer? in
var peers: [RenderedPeer] = []
peers.append(contentsOf: transaction.getTopChatListEntries(groupId: .root, count: 100))
peers.append(contentsOf: transaction.getTopChatListEntries(groupId: Namespaces.PeerGroup.archive, count: 100))
var results: [(EnginePeer, Int32)] = []
for peer in peers {
guard let channel = peer.chatMainPeer as? TelegramChannel, case .broadcast = channel.info else {
continue
}
if channel.id == peerId {
continue
}
guard let readState = transaction.getCombinedPeerReadState(channel.id), readState.count != 0 else {
continue
}
guard let topMessageIndex = transaction.getTopPeerMessageIndex(peerId: channel.id) else {
continue
}
results.append((EnginePeer(channel), topMessageIndex.timestamp))
}
results.sort(by: { $0.1 > $1.1 })
return results.first?.0
}
}
}
}

View File

@ -238,6 +238,7 @@ swift_library(
"//submodules/ImportStickerPackUI:ImportStickerPackUI",
"//submodules/GradientBackground:GradientBackground",
"//submodules/WallpaperBackgroundNode:WallpaperBackgroundNode",
"//submodules/ComponentFlow:ComponentFlow",
] + select({
"@build_bazel_rules_apple//apple:ios_armv7": [],
"@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets,

View File

@ -454,6 +454,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
}
private var importStateDisposable: Disposable?
private var nextChannelToReadDisposable: Disposable?
public init(context: AccountContext, chatLocation: ChatLocation, chatLocationContextHolder: Atomic<ChatLocationContextHolder?> = Atomic<ChatLocationContextHolder?>(value: nil), subject: ChatControllerSubject? = nil, botStart: ChatControllerInitialBotStart? = nil, mode: ChatControllerPresentationMode = .standard(previewing: false), peekData: ChatPeekTimeout? = nil, peerNearbyData: ChatPeerNearbyData? = nil) {
let _ = ChatControllerCount.modify { value in
@ -3295,6 +3297,23 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
}.updatedIsNotAccessible(isNotAccessible).updatedContactStatus(contactStatus).updatedHasBots(hasBots).updatedHasBotCommands(hasBotCommands).updatedIsArchived(isArchived).updatedPeerIsMuted(peerIsMuted).updatedPeerDiscussionId(peerDiscussionId).updatedPeerGeoLocation(peerGeoLocation).updatedExplicitelyCanPinMessages(explicitelyCanPinMessages).updatedHasScheduledMessages(hasScheduledMessages)
.updatedAutoremoveTimeout(autoremoveTimeout)
})
if let channel = renderedPeer?.chatMainPeer as? TelegramChannel, case .broadcast = channel.info {
if strongSelf.nextChannelToReadDisposable == nil {
strongSelf.nextChannelToReadDisposable = (strongSelf.context.engine.peers.getNextUnreadChannel(peerId: channel.id)
|> deliverOnMainQueue
|> then(.complete() |> delay(1.0, queue: .mainQueue()))
|> restart).start(next: { nextPeer in
guard let strongSelf = self else {
return
}
strongSelf.chatDisplayNode.historyNode.offerNextChannelToRead = true
strongSelf.chatDisplayNode.historyNode.nextChannelToRead = nextPeer
})
}
}
if !strongSelf.didSetChatLocationInfoReady {
strongSelf.didSetChatLocationInfoReady = true
strongSelf._chatLocationInfoReady.set(.single(true))
@ -3867,6 +3886,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
self.selectAddMemberDisposable.dispose()
self.addMemberDisposable.dispose()
self.importStateDisposable?.dispose()
self.nextChannelToReadDisposable?.dispose()
}
public func updatePresentationMode(_ mode: ChatControllerPresentationMode) {
@ -7011,9 +7031,28 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
}
}
})
self.chatDisplayNode.historyNode.openNextChannelToRead = { [weak self] peer in
guard let strongSelf = self else {
return
}
if let navigationController = strongSelf.effectiveNavigationController, let snapshotView = strongSelf.chatDisplayNode.view.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = strongSelf.view.bounds
strongSelf.view.addSubview(snapshotView)
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer.id), animated: false, completion: { nextController in
(nextController as! ChatControllerImpl).animateFromPreviousController(snapshotView: snapshotView)
}))
}
}
self.displayNodeDidLoad()
}
private var storedAnimateFromSnapshotView: UIView?
private func animateFromPreviousController(snapshotView: UIView) {
self.storedAnimateFromSnapshotView = snapshotView
}
override public func viewWillAppear(_ animated: Bool) {
#if DEBUG
@ -7415,6 +7454,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
return state.updatedInputMode({ _ in .text })
})
}
if let snapshotView = self.storedAnimateFromSnapshotView {
self.storedAnimateFromSnapshotView = nil
snapshotView.frame = self.view.bounds.offsetBy(dx: 0.0, dy: -self.view.bounds.height)
self.view.insertSubview(snapshotView, at: 0)
self.view.layer.animateBoundsOriginYAdditive(from: -self.view.bounds.height, to: 0.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: true, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
}
}
override public func viewWillDisappear(_ animated: Bool) {

View File

@ -17,6 +17,7 @@ import ListMessageItem
import AccountContext
import ChatInterfaceState
import ChatListUI
import ComponentFlow
extension ChatReplyThreadMessage {
var effectiveTopId: MessageId {
@ -546,6 +547,13 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode {
let topVisibleMessageRange = ValuePromise<ChatTopVisibleMessageRange?>(nil, ignoreRepeated: true)
var isSelectionGestureEnabled = true
private var overscrollView: ComponentHostView<Empty>?
var nextChannelToRead: EnginePeer?
var offerNextChannelToRead: Bool = false
private var currentOverscrollExpandProgress: CGFloat = 0.0
private var feedback: HapticFeedback?
var openNextChannelToRead: ((EnginePeer) -> Void)?
private let clientId: Atomic<Int32>
@ -1115,11 +1123,14 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode {
if strongSelf.tagMask == nil {
var atBottom = false
var offsetFromBottom: CGFloat?
switch offset {
case let .known(offsetValue):
if offsetValue.isLessThanOrEqualTo(0.0) {
atBottom = true
offsetFromBottom = offsetValue
}
//print("offsetValue: \(offsetValue)")
default:
break
}
@ -1130,6 +1141,8 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode {
strongSelf.isScrollAtBottomPositionUpdated?()
}
strongSelf.maybeUpdateOverscrollAction(offset: offsetFromBottom)
}
}
}
@ -1150,10 +1163,22 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode {
self?.isInteractivelyScrollingPromise.set(true)
self?.beganDragging?()
}
self.endedInteractiveDragging = { [weak self] in
guard let strongSelf = self else {
return
}
if let channel = strongSelf.nextChannelToRead, strongSelf.currentOverscrollExpandProgress >= 0.99 {
strongSelf.openNextChannelToRead?(channel)
}
}
self.didEndScrolling = { [weak self] in
self?.isInteractivelyScrollingValue = false
self?.isInteractivelyScrollingPromise.set(false)
guard let strongSelf = self else {
return
}
strongSelf.isInteractivelyScrollingValue = false
strongSelf.isInteractivelyScrollingPromise.set(false)
}
let selectionRecognizer = ChatHistoryListSelectionRecognizer(target: self, action: #selector(self.selectionPanGesture(_:)))
@ -1177,6 +1202,64 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode {
public func setLoadStateUpdated(_ f: @escaping (ChatHistoryNodeLoadState, Bool) -> Void) {
self.loadStateUpdated = f
}
private func maybeUpdateOverscrollAction(offset: CGFloat?) {
if let offset = offset, offset < 0.0, self.offerNextChannelToRead {
let overscrollView: ComponentHostView<Empty>
if let current = self.overscrollView {
overscrollView = current
} else {
overscrollView = ComponentHostView<Empty>()
overscrollView.layer.sublayerTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
self.overscrollView = overscrollView
self.view.addSubview(overscrollView)
}
let expandProgress: CGFloat = min(1.0, max(-offset, 0.0) / 110.0)
let text: String
if self.nextChannelToRead != nil {
if expandProgress >= 0.99 {
//TODO:localize
text = "Release to go to the next unread channel"
} else {
text = "Swipe up to go to the next unread channel"
}
let previousType = self.currentOverscrollExpandProgress >= 0.99
let currentType = expandProgress >= 0.99
if previousType != currentType {
if self.feedback == nil {
self.feedback = HapticFeedback()
}
self.feedback?.tap()
}
self.currentOverscrollExpandProgress = expandProgress
} else {
text = "You have no unread channels"
}
let overscrollSize = overscrollView.update(
transition: .immediate,
component: AnyComponent(ChatOverscrollControl(
text: text,
backgroundColor: selectDateFillStaticColor(theme: self.currentPresentationData.theme.theme, wallpaper: self.currentPresentationData.theme.wallpaper),
foregroundColor: bubbleVariableColor(variableColor: self.currentPresentationData.theme.theme.chat.serviceMessage.dateTextColor, wallpaper: self.currentPresentationData.theme.wallpaper),
peer: self.nextChannelToRead,
context: self.context,
expandProgress: expandProgress
)),
environment: {},
containerSize: CGSize(width: self.bounds.width, height: 200.0)
)
overscrollView.frame = CGRect(origin: CGPoint(x: floor((self.bounds.width - overscrollSize.width) / 2.0), y: -offset + self.insets.top - overscrollSize.height - 10.0), size: overscrollSize)
} else if let overscrollView = self.overscrollView {
self.overscrollView = nil
overscrollView.removeFromSuperview()
}
}
func refreshPollActionsForVisibleMessages() {
let _ = self.clientId.swap(nextClientId)

View File

@ -0,0 +1,518 @@
import UIKit
import ComponentFlow
import Display
import TelegramCore
import Postbox
import AccountContext
import AvatarNode
final class BlurredRoundedRectangle: Component {
let color: UIColor
init(color: UIColor) {
self.color = color
}
static func ==(lhs: BlurredRoundedRectangle, rhs: BlurredRoundedRectangle) -> Bool {
if !lhs.color.isEqual(rhs.color) {
return false
}
return true
}
final class View: UIView {
private let background: NavigationBackgroundNode
init() {
self.background = NavigationBackgroundNode(color: .clear)
super.init(frame: CGRect())
self.addSubview(self.background.view)
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
func update(component: BlurredRoundedRectangle, availableSize: CGSize, transition: Transition) -> CGSize {
transition.setFrame(view: self.background.view, frame: CGRect(origin: CGPoint(), size: availableSize))
self.background.updateColor(color: component.color, transition: .immediate)
self.background.update(size: availableSize, cornerRadius: min(availableSize.width, availableSize.height) / 2.0, transition: .immediate)
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
final class RadialProgressComponent: Component {
let color: UIColor
let lineWidth: CGFloat
let value: CGFloat
init(
color: UIColor,
lineWidth: CGFloat,
value: CGFloat
) {
self.color = color
self.lineWidth = lineWidth
self.value = value
}
static func ==(lhs: RadialProgressComponent, rhs: RadialProgressComponent) -> Bool {
if !lhs.color.isEqual(rhs.color) {
return false
}
if lhs.lineWidth != rhs.lineWidth {
return false
}
if lhs.value != rhs.value {
return false
}
return true
}
final class View: UIView {
init() {
super.init(frame: CGRect())
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
func update(component: RadialProgressComponent, availableSize: CGSize, transition: Transition) -> CGSize {
func draw(context: CGContext) {
let diameter = availableSize.width
context.saveGState()
context.setBlendMode(.normal)
context.setFillColor(component.color.cgColor)
context.setStrokeColor(component.color.cgColor)
var progress: CGFloat
var startAngle: CGFloat
var endAngle: CGFloat
let value = component.value
progress = value
startAngle = -CGFloat.pi / 2.0
endAngle = CGFloat(progress) * 2.0 * CGFloat.pi + startAngle
if progress > 1.0 {
progress = 2.0 - progress
let tmp = startAngle
startAngle = endAngle
endAngle = tmp
}
progress = min(1.0, progress)
let lineWidth: CGFloat = component.lineWidth
let pathDiameter: CGFloat
pathDiameter = diameter - lineWidth
var angle: Double = 0.0
angle *= 4.0
context.translateBy(x: diameter / 2.0, y: diameter / 2.0)
context.rotate(by: CGFloat(angle.truncatingRemainder(dividingBy: Double.pi * 2.0)))
context.translateBy(x: -diameter / 2.0, y: -diameter / 2.0)
let path = UIBezierPath(arcCenter: CGPoint(x: diameter / 2.0, y: diameter / 2.0), radius: pathDiameter / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.lineWidth = lineWidth
path.lineCapStyle = .round
path.stroke()
context.restoreGState()
}
if #available(iOS 10.0, *) {
let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: availableSize))
let image = renderer.image { context in
UIGraphicsPushContext(context.cgContext)
draw(context: context.cgContext)
UIGraphicsPopContext()
}
self.layer.contents = image.cgImage
} else {
UIGraphicsBeginImageContextWithOptions(availableSize, false, 0.0)
draw(context: UIGraphicsGetCurrentContext()!)
self.layer.contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage
UIGraphicsEndImageContext()
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
final class CheckComponent: Component {
let color: UIColor
let lineWidth: CGFloat
let value: CGFloat
init(
color: UIColor,
lineWidth: CGFloat,
value: CGFloat
) {
self.color = color
self.lineWidth = lineWidth
self.value = value
}
static func ==(lhs: CheckComponent, rhs: CheckComponent) -> Bool {
if !lhs.color.isEqual(rhs.color) {
return false
}
if lhs.lineWidth != rhs.lineWidth {
return false
}
if lhs.value != rhs.value {
return false
}
return true
}
final class View: UIView {
init() {
super.init(frame: CGRect())
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
func update(component: CheckComponent, availableSize: CGSize, transition: Transition) -> CGSize {
func draw(context: CGContext) {
let size = availableSize
let diameter = size.width
let factor = diameter / 50.0
context.saveGState()
context.setBlendMode(.normal)
context.setFillColor(component.color.cgColor)
context.setStrokeColor(component.color.cgColor)
let center = CGPoint(x: diameter / 2.0, y: diameter / 2.0)
let lineWidth = component.lineWidth
context.setLineWidth(max(1.7, lineWidth * factor))
context.setLineCap(.round)
context.setLineJoin(.round)
context.setMiterLimit(10.0)
let progress = component.value
let firstSegment: CGFloat = max(0.0, min(1.0, progress * 3.0))
var s = CGPoint(x: center.x - 10.0 * factor, y: center.y + 1.0 * factor)
var p1 = CGPoint(x: 7.0 * factor, y: 7.0 * factor)
var p2 = CGPoint(x: 13.0 * factor, y: -15.0 * factor)
if diameter < 36.0 {
s = CGPoint(x: center.x - 7.0 * factor, y: center.y + 1.0 * factor)
p1 = CGPoint(x: 4.5 * factor, y: 4.5 * factor)
p2 = CGPoint(x: 10.0 * factor, y: -11.0 * factor)
}
if !firstSegment.isZero {
if firstSegment < 1.0 {
context.move(to: CGPoint(x: s.x + p1.x * firstSegment, y: s.y + p1.y * firstSegment))
context.addLine(to: s)
} else {
let secondSegment = (progress - 0.33) * 1.5
context.move(to: CGPoint(x: s.x + p1.x + p2.x * secondSegment, y: s.y + p1.y + p2.y * secondSegment))
context.addLine(to: CGPoint(x: s.x + p1.x, y: s.y + p1.y))
context.addLine(to: s)
}
}
context.strokePath()
context.restoreGState()
}
if #available(iOS 10.0, *) {
let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: availableSize))
let image = renderer.image { context in
UIGraphicsPushContext(context.cgContext)
draw(context: context.cgContext)
UIGraphicsPopContext()
}
self.layer.contents = image.cgImage
} else {
UIGraphicsBeginImageContextWithOptions(availableSize, false, 0.0)
draw(context: UIGraphicsGetCurrentContext()!)
self.layer.contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage
UIGraphicsEndImageContext()
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
final class AvatarComponent: Component {
let context: AccountContext
let peer: EnginePeer
init(context: AccountContext, peer: EnginePeer) {
self.context = context
self.peer = peer
}
static func ==(lhs: AvatarComponent, rhs: AvatarComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peer != rhs.peer {
return false
}
return true
}
final class View: UIView {
private let avatarNode: AvatarNode
init() {
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0))
super.init(frame: CGRect())
self.addSubview(self.avatarNode.view)
}
required init?(coder aDecoder: NSCoder) {
preconditionFailure()
}
func update(component: AvatarComponent, availableSize: CGSize, transition: Transition) -> CGSize {
self.avatarNode.frame = CGRect(origin: CGPoint(), size: availableSize)
self.avatarNode.setPeer(context: component.context, theme: component.context.sharedContext.currentPresentationData.with({ $0 }).theme, peer: component.peer._asPeer(), synchronousLoad: true)
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
final class ChatOverscrollControl: CombinedComponent {
let text: String
let backgroundColor: UIColor
let foregroundColor: UIColor
let peer: EnginePeer?
let context: AccountContext
let expandProgress: CGFloat
init(
text: String,
backgroundColor: UIColor,
foregroundColor: UIColor,
peer: EnginePeer?,
context: AccountContext,
expandProgress: CGFloat
) {
self.text = text
self.backgroundColor = backgroundColor
self.foregroundColor = foregroundColor
self.peer = peer
self.context = context
self.expandProgress = expandProgress
}
static func ==(lhs: ChatOverscrollControl, rhs: ChatOverscrollControl) -> Bool {
if lhs.text != rhs.text {
return false
}
if !lhs.backgroundColor.isEqual(rhs.backgroundColor) {
return false
}
if !lhs.foregroundColor.isEqual(rhs.foregroundColor) {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.context !== rhs.context {
return false
}
if lhs.expandProgress != rhs.expandProgress {
return false
}
return true
}
static var body: Body {
let avatarBackground = Child(BlurredRoundedRectangle.self)
let avatarExpandProgress = Child(RadialProgressComponent.self)
let avatarCheck = Child(CheckComponent.self)
let avatar = Child(AvatarComponent.self)
let textBackground = Child(BlurredRoundedRectangle.self)
let text = Child(Text.self)
return { context in
let text = text.update(
component: Text(
text: context.component.text,
font: Font.regular(12.0),
color: context.component.foregroundColor
),
availableSize: CGSize(width: context.availableSize.width, height: 100.0),
transition: context.transition
)
let textHorizontalPadding: CGFloat = 6.0
let textVerticalPadding: CGFloat = 2.0
let avatarSize: CGFloat = 48.0
let avatarPadding: CGFloat = 8.0
let avatarTextSpacing: CGFloat = 8.0
let avatarProgressPadding: CGFloat = 2.5
let avatarBackgroundSize: CGFloat = context.component.peer != nil ? (avatarSize + avatarPadding * 2.0) : avatarSize
let avatarBackground = avatarBackground.update(
component: BlurredRoundedRectangle(
color: context.component.backgroundColor
),
availableSize: CGSize(width: avatarBackgroundSize, height: avatarBackgroundSize),
transition: context.transition
)
let avatarCheck = Condition(context.component.peer == nil, { () -> _UpdatedChildComponent in
let avatarCheckSize = avatarBackgroundSize + 2.0
return avatarCheck.update(
component: CheckComponent(
color: context.component.foregroundColor,
lineWidth: 2.5,
value: 1.0
),
availableSize: CGSize(width: avatarCheckSize, height: avatarCheckSize),
transition: context.transition
)
})
let avatarExpandProgress = avatarExpandProgress.update(
component: RadialProgressComponent(
color: context.component.foregroundColor,
lineWidth: 2.5,
value: context.component.peer == nil ? 0.0 : context.component.expandProgress
),
availableSize: CGSize(width: avatarBackground.size.width - avatarProgressPadding * 2.0, height: avatarBackground.size.height - avatarProgressPadding * 2.0),
transition: context.transition
)
let textBackground = textBackground.update(
component: BlurredRoundedRectangle(
color: context.component.backgroundColor
),
availableSize: CGSize(width: text.size.width + textHorizontalPadding * 2.0, height: text.size.height + textVerticalPadding * 2.0),
transition: context.transition
)
let size = CGSize(width: context.availableSize.width, height: avatarBackground.size.height + avatarTextSpacing + textBackground.size.height)
let avatarBackgroundFrame = avatarBackground.size.topCentered(in: CGRect(origin: CGPoint(), size: size))
let avatar = context.component.peer.flatMap { peer in
avatar.update(
component: AvatarComponent(
context: context.component.context,
peer: peer
),
availableSize: CGSize(width: avatarSize, height: avatarSize),
transition: context.transition
)
}
context.add(avatarBackground
.position(CGPoint(
x: avatarBackgroundFrame.midX,
y: avatarBackgroundFrame.midY
))
)
if let avatarCheck = avatarCheck {
context.add(avatarCheck
.position(CGPoint(
x: avatarBackgroundFrame.midX,
y: avatarBackgroundFrame.midY
))
)
}
context.add(avatarExpandProgress
.position(CGPoint(
x: avatarBackgroundFrame.midX,
y: avatarBackgroundFrame.midY
))
)
if let avatar = avatar {
context.add(avatar
.position(CGPoint(
x: avatarBackgroundFrame.midX,
y: avatarBackgroundFrame.midY
))
)
}
let textBackgroundFrame = textBackground.size.bottomCentered(in: CGRect(origin: CGPoint(), size: size))
context.add(textBackground
.position(CGPoint(
x: textBackgroundFrame.midX,
y: textBackgroundFrame.midY
))
)
let textFrame = text.size.centered(in: textBackgroundFrame)
context.add(text
.position(CGPoint(
x: textFrame.midX,
y: textFrame.midY
))
)
return size
}
}
}