Create MainWindow folder to match Mac folder structure.

This commit is contained in:
Brent Simmons
2025-02-02 11:33:09 -08:00
parent a8c13a6fdc
commit b294fbcc58
47 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,408 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina5_9" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Add Web Feed-->
<scene sceneID="2Tc-JN-edX">
<objects>
<tableViewController storyboardIdentifier="AddFeedViewController" id="7aE-6a-iP7" customClass="AddFeedViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" scrollEnabled="NO" dataMode="static" style="insetGrouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="D0S-TM-mtm">
<rect key="frame" x="0.0" y="0.0" width="375" height="812"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<sections>
<tableViewSection id="3tl-Mb-Eno">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" reuseIdentifier="Cell" rowHeight="44" id="lyJ-rf-8GA">
<rect key="frame" x="16" y="18" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="lyJ-rf-8GA" id="eNS-Rp-w0A">
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="URL" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="eRp-AP-WFq">
<rect key="frame" x="20" y="4" width="323" height="36"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" keyboardType="URL"/>
</textField>
</subviews>
<constraints>
<constraint firstItem="eRp-AP-WFq" firstAttribute="top" secondItem="eNS-Rp-w0A" secondAttribute="top" constant="4" id="80p-a2-3NC"/>
<constraint firstAttribute="trailing" secondItem="eRp-AP-WFq" secondAttribute="trailing" id="Xue-v3-aqR"/>
<constraint firstItem="eRp-AP-WFq" firstAttribute="leading" secondItem="eNS-Rp-w0A" secondAttribute="leading" constant="20" symbolic="YES" id="bHJ-7l-Pl3"/>
<constraint firstAttribute="bottom" secondItem="eRp-AP-WFq" secondAttribute="bottom" constant="4" id="fs0-iw-zTo"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="44" id="Pxz-fv-QhQ">
<rect key="frame" x="16" y="62" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Pxz-fv-QhQ" id="8aP-2A-8jc">
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Title (Optional)" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="u7n-VL-Ho9">
<rect key="frame" x="20" y="4" width="303" height="36"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" autocapitalizationType="words"/>
</textField>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="u7n-VL-Ho9" secondAttribute="bottom" constant="4" id="CdB-LH-PJT"/>
<constraint firstItem="u7n-VL-Ho9" firstAttribute="leading" secondItem="8aP-2A-8jc" secondAttribute="leading" constant="20" symbolic="YES" id="RML-Iw-gsd"/>
<constraint firstItem="u7n-VL-Ho9" firstAttribute="top" secondItem="8aP-2A-8jc" secondAttribute="top" constant="4" id="dxi-LX-hFa"/>
<constraint firstAttribute="trailing" secondItem="u7n-VL-Ho9" secondAttribute="trailing" constant="20" symbolic="YES" id="kQl-v5-eVa"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" rowHeight="44" id="rlc-34-flT">
<rect key="frame" x="16" y="106" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="rlc-34-flT" id="ZbC-Z6-dtq">
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Folder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="RtT-rR-5LA">
<rect key="frame" x="19.999999999999996" y="11.666666666666664" width="48.666666666666657" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="htg-Nn-3xi">
<rect key="frame" x="281" y="11.666666666666664" width="42" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="htg-Nn-3xi" firstAttribute="centerY" secondItem="ZbC-Z6-dtq" secondAttribute="centerY" id="Z4n-Wi-s5H"/>
<constraint firstItem="htg-Nn-3xi" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="RtT-rR-5LA" secondAttribute="trailing" constant="8" symbolic="YES" id="grG-sv-OgE"/>
<constraint firstAttribute="trailing" secondItem="htg-Nn-3xi" secondAttribute="trailing" constant="20" symbolic="YES" id="izx-09-DWe"/>
<constraint firstItem="RtT-rR-5LA" firstAttribute="leading" secondItem="ZbC-Z6-dtq" secondAttribute="leading" constant="20" symbolic="YES" id="q2d-LR-YGh"/>
<constraint firstItem="RtT-rR-5LA" firstAttribute="centerY" secondItem="ZbC-Z6-dtq" secondAttribute="centerY" id="wHc-B0-lLE"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="7aE-6a-iP7" id="PAe-eu-KhR"/>
<outlet property="delegate" destination="7aE-6a-iP7" id="zYS-q2-iEf"/>
</connections>
</tableView>
<toolbarItems/>
<navigationItem key="navigationItem" title="Add Web Feed" id="i1W-2z-PAk">
<barButtonItem key="leftBarButtonItem" title="Cancel" id="QpU-Ro-nHd">
<connections>
<action selector="cancel:" destination="7aE-6a-iP7" id="g4H-Mc-Jnh"/>
</connections>
</barButtonItem>
<rightBarButtonItems>
<barButtonItem enabled="NO" title="Add" style="done" id="F8z-me-Hnx">
<connections>
<action selector="add:" destination="7aE-6a-iP7" id="66n-Ea-YXO"/>
</connections>
</barButtonItem>
<barButtonItem id="qP4-8x-XbO">
<view key="customView" contentMode="scaleToFill" id="9ct-kH-nAp">
<rect key="frame" x="300.33333333333331" y="12" width="20" height="20"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="3ph-Td-s1Z">
<rect key="frame" x="0.0" y="0.0" width="20" height="20"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
</activityIndicatorView>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
</barButtonItem>
</rightBarButtonItems>
</navigationItem>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>
<connections>
<outlet property="activityIndicator" destination="3ph-Td-s1Z" id="ouh-78-smj"/>
<outlet property="addButton" destination="F8z-me-Hnx" id="l1b-UL-238"/>
<outlet property="nameTextField" destination="u7n-VL-Ho9" id="YQV-Xq-f9q"/>
<outlet property="urlTextField" destination="eRp-AP-WFq" id="FG3-pH-2Fh"/>
<outlet property="urlTextFieldToSuperViewConstraint" destination="Xue-v3-aqR" id="ZcO-5b-g08"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="TO9-rb-MQ7" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-1711" y="89"/>
</scene>
<!--Modal Navigation Controller-->
<scene sceneID="hbe-Yu-anW">
<objects>
<navigationController storyboardIdentifier="AddFeedFolderNavViewController" id="WDg-F7-gfk" customClass="ModalNavigationController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="akM-T1-shZ">
<rect key="frame" x="0.0" y="44" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="acA-n7-ohN" kind="relationship" relationship="rootViewController" id="L5I-0H-Oci"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ndl-Bj-xD7" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-1711" y="756"/>
</scene>
<!--Web Feed Folder-->
<scene sceneID="wnD-aY-W32">
<objects>
<tableViewController storyboardIdentifier="AddFeedFolderViewController" title="Web Feed Folder" id="acA-n7-ohN" customClass="AddFeedFolderViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="new-nW-ba2">
<rect key="frame" x="0.0" y="0.0" width="375" height="812"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="AccountCell" id="bp5-2u-4S4" customClass="AddComboTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="28" width="375" height="43.666667938232422"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="bp5-2u-4S4" id="9HE-eR-YIp">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.666667938232422"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="CIM-Ah-2kX">
<rect key="frame" x="20" y="11" width="22" height="22"/>
<color key="tintColor" name="secondaryAccentColor"/>
<constraints>
<constraint firstAttribute="width" constant="22" id="NwD-N9-OGB"/>
<constraint firstAttribute="height" constant="22" id="wE0-VQ-qk9"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Account" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZQp-94-vJz">
<rect key="frame" x="50" y="11.333333333333336" width="64" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="ZQp-94-vJz" firstAttribute="centerY" secondItem="9HE-eR-YIp" secondAttribute="centerY" id="1Qs-JW-lzY"/>
<constraint firstItem="CIM-Ah-2kX" firstAttribute="leading" secondItem="9HE-eR-YIp" secondAttribute="leading" constant="20" symbolic="YES" id="5tt-v2-b7C"/>
<constraint firstItem="CIM-Ah-2kX" firstAttribute="centerY" secondItem="9HE-eR-YIp" secondAttribute="centerY" id="PYb-j8-jvd"/>
<constraint firstItem="ZQp-94-vJz" firstAttribute="leading" secondItem="CIM-Ah-2kX" secondAttribute="trailing" constant="8" id="vUA-hy-nM5"/>
</constraints>
</tableViewCellContentView>
<inset key="separatorInset" minX="45" minY="0.0" maxX="0.0" maxY="0.0"/>
<connections>
<outlet property="icon" destination="CIM-Ah-2kX" id="th3-LO-z9R"/>
<outlet property="label" destination="ZQp-94-vJz" id="Jm5-oU-BZR"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="FolderCell" id="S24-c1-0Ir" customClass="AddComboTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="71.666667938232422" width="375" height="43.666667938232422"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="S24-c1-0Ir" id="bA3-AB-H1n">
<rect key="frame" x="0.0" y="0.0" width="375" height="43.666667938232422"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="yKC-at-6Jx">
<rect key="frame" x="50" y="11" width="22" height="22"/>
<color key="tintColor" name="secondaryAccentColor"/>
<constraints>
<constraint firstAttribute="height" constant="22" id="1B1-qh-G3r"/>
<constraint firstAttribute="width" constant="22" id="1nz-Dw-URx"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Folder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3Tp-A7-7rS">
<rect key="frame" x="80" y="11.333333333333336" width="49" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="3Tp-A7-7rS" firstAttribute="leading" secondItem="yKC-at-6Jx" secondAttribute="trailing" constant="8" id="If3-mO-byK"/>
<constraint firstItem="yKC-at-6Jx" firstAttribute="centerY" secondItem="bA3-AB-H1n" secondAttribute="centerY" id="W9P-Mv-kKk"/>
<constraint firstItem="3Tp-A7-7rS" firstAttribute="centerY" secondItem="bA3-AB-H1n" secondAttribute="centerY" id="YVe-hB-Whm"/>
<constraint firstItem="yKC-at-6Jx" firstAttribute="leading" secondItem="bA3-AB-H1n" secondAttribute="leading" constant="50" id="vGI-al-tZg"/>
</constraints>
</tableViewCellContentView>
<inset key="separatorInset" minX="80" minY="0.0" maxX="0.0" maxY="0.0"/>
<connections>
<outlet property="icon" destination="yKC-at-6Jx" id="xOi-y6-6BX"/>
<outlet property="label" destination="3Tp-A7-7rS" id="zjf-mw-Tch"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="acA-n7-ohN" id="fTR-qI-ea7"/>
<outlet property="delegate" destination="acA-n7-ohN" id="x4s-jR-1Aw"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Select Folder" id="e4Y-zR-cTn">
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="aTX-Fj-7pf">
<connections>
<action selector="cancel:" destination="acA-n7-ohN" id="euP-3Q-f0K"/>
</connections>
</barButtonItem>
</navigationItem>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="gE2-fq-mL5" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-962" y="756"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="1In-mk-bYI">
<objects>
<navigationController storyboardIdentifier="AddFeedViewControllerNav" id="4Ej-aI-tUP" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="Fot-QF-7Rn">
<rect key="frame" x="0.0" y="44" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="7aE-6a-iP7" kind="relationship" relationship="rootViewController" id="4tH-Fk-eD2"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="8GA-I5-K0s" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-1711" y="-611"/>
</scene>
<!--Add Folder-->
<scene sceneID="m7L-uI-ghq">
<objects>
<tableViewController id="3dI-34-ljo" customClass="AddFolderViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" scrollEnabled="NO" dataMode="static" style="insetGrouped" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="7xa-gZ-zHA">
<rect key="frame" x="0.0" y="0.0" width="375" height="812"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<sections>
<tableViewSection id="12M-tp-EeV">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="44" id="XJI-0M-cAh">
<rect key="frame" x="16" y="18" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="XJI-0M-cAh" id="tIS-Tx-i17">
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Name" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="lZK-wx-jbo">
<rect key="frame" x="20" y="4" width="303" height="36"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" autocapitalizationType="words"/>
</textField>
</subviews>
<constraints>
<constraint firstItem="lZK-wx-jbo" firstAttribute="leading" secondItem="tIS-Tx-i17" secondAttribute="leading" constant="20" symbolic="YES" id="62N-uM-0fD"/>
<constraint firstAttribute="trailing" secondItem="lZK-wx-jbo" secondAttribute="trailing" constant="20" symbolic="YES" id="6b0-Xh-58P"/>
<constraint firstAttribute="bottom" secondItem="lZK-wx-jbo" secondAttribute="bottom" constant="4" id="QRV-tV-NB8"/>
<constraint firstItem="lZK-wx-jbo" firstAttribute="top" secondItem="tIS-Tx-i17" secondAttribute="top" constant="4" id="dli-qZ-hdA"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection id="bX9-Y2-S2D">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="44" id="uU0-Jh-goT">
<rect key="frame" x="16" y="98" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="uU0-Jh-goT" id="y2g-dW-fPZ">
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Account" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YRf-I7-nkL">
<rect key="frame" x="20" y="4" width="64" height="36"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mxj-Bw-Jfx">
<rect key="frame" x="281" y="12" width="42" height="20"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="YRf-I7-nkL" firstAttribute="top" secondItem="y2g-dW-fPZ" secondAttribute="top" constant="4" id="7ey-y0-Aef"/>
<constraint firstItem="mxj-Bw-Jfx" firstAttribute="centerY" secondItem="YRf-I7-nkL" secondAttribute="centerY" id="FDk-71-7yD"/>
<constraint firstAttribute="trailing" secondItem="mxj-Bw-Jfx" secondAttribute="trailing" constant="20" id="fIA-Rb-SEi"/>
<constraint firstAttribute="bottom" secondItem="YRf-I7-nkL" secondAttribute="bottom" constant="4" id="fcs-rL-KrO"/>
<constraint firstItem="mxj-Bw-Jfx" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="YRf-I7-nkL" secondAttribute="trailing" constant="8" id="rQ8-3l-2gz"/>
<constraint firstItem="YRf-I7-nkL" firstAttribute="leading" secondItem="y2g-dW-fPZ" secondAttribute="leading" constant="20" symbolic="YES" id="xBK-tz-P2j"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="140" id="zRi-p6-4KU">
<rect key="frame" x="16" y="142" width="343" height="140"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="zRi-p6-4KU" id="wek-Qh-OXr">
<rect key="frame" x="0.0" y="0.0" width="343" height="140"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<pickerView contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="eGY-V8-gzJ">
<rect key="frame" x="0.0" y="0.0" width="343" height="140"/>
</pickerView>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="eGY-V8-gzJ" secondAttribute="trailing" id="Ghf-jL-Xrr"/>
<constraint firstItem="eGY-V8-gzJ" firstAttribute="top" secondItem="wek-Qh-OXr" secondAttribute="top" id="Ldh-IT-4bH"/>
<constraint firstItem="eGY-V8-gzJ" firstAttribute="leading" secondItem="wek-Qh-OXr" secondAttribute="leading" id="cTY-21-rTK"/>
<constraint firstAttribute="bottom" secondItem="eGY-V8-gzJ" secondAttribute="bottom" id="h5X-Sg-5mV"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="3dI-34-ljo" id="Bz5-33-FXs"/>
<outlet property="delegate" destination="3dI-34-ljo" id="ZXX-gI-6ii"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Add Folder" id="LuA-AC-n99">
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="OPb-pt-GzR">
<connections>
<action selector="cancel:" destination="3dI-34-ljo" id="LLG-Xw-5nu"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" enabled="NO" title="Add" style="done" id="gXG-Xt-d9B">
<connections>
<action selector="add:" destination="3dI-34-ljo" id="d1a-W6-8ST"/>
</connections>
</barButtonItem>
</navigationItem>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<connections>
<outlet property="accountLabel" destination="mxj-Bw-Jfx" id="l4A-ta-dUi"/>
<outlet property="accountPickerView" destination="eGY-V8-gzJ" id="zGk-k4-Jnd"/>
<outlet property="addButton" destination="gXG-Xt-d9B" id="BW0-Q7-stj"/>
<outlet property="nameTextField" destination="lZK-wx-jbo" id="Xvq-LH-kwC"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="yp4-mF-DZV" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-961" y="89"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="Hm3-Dz-Cpu">
<objects>
<navigationController storyboardIdentifier="AddFolderViewControllerNav" id="edk-IE-nce" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="Pi1-Bc-YXQ">
<rect key="frame" x="0.0" y="44" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="3dI-34-ljo" kind="relationship" relationship="rootViewController" id="nk7-pM-zHX"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ivk-RR-1yS" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-962" y="-618"/>
</scene>
</scenes>
<resources>
<namedColor name="secondaryAccentColor">
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -0,0 +1,30 @@
//
// AddComboTableViewCell.swift
// NetNewsWire
//
// Created by Maurice Parker on 11/16/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
final class AddComboTableViewCell: VibrantTableViewCell {
@IBOutlet weak var icon: UIImageView!
@IBOutlet weak var label: UILabel!
override func updateVibrancy(animated: Bool) {
super.updateVibrancy(animated: animated)
let iconTintColor = isHighlighted || isSelected ? AppColor.vibrantText : AppColor.secondaryAccent
if animated {
UIView.animate(withDuration: Self.duration) {
self.icon.tintColor = iconTintColor
}
} else {
self.icon.tintColor = iconTintColor
}
updateLabelVibrancy(label, color: labelColor, animated: animated)
}
}

View File

@@ -0,0 +1,101 @@
//
// AddFeedFolderViewController.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/16/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
import Account
protocol AddFeedFolderViewControllerDelegate: AnyObject {
func didSelect(container: Container)
}
final class AddFeedFolderViewController: UITableViewController {
weak var delegate: AddFeedFolderViewControllerDelegate?
var initialContainer: Container?
var containers = [Container]()
override func viewDidLoad() {
super.viewDidLoad()
let sortedActiveAccounts = AccountManager.shared.sortedActiveAccounts
for account in sortedActiveAccounts {
containers.append(account)
if let sortedFolders = account.sortedFolders {
containers.append(contentsOf: sortedFolders)
}
}
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return containers.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let container = containers[indexPath.row]
let cell: AddComboTableViewCell = {
if container is Account {
return tableView.dequeueReusableCell(withIdentifier: "AccountCell", for: indexPath) as! AddComboTableViewCell
} else {
return tableView.dequeueReusableCell(withIdentifier: "FolderCell", for: indexPath) as! AddComboTableViewCell
}
}()
if let smallIconProvider = container as? SmallIconProvider {
cell.icon?.image = smallIconProvider.smallIcon?.image
}
if let displayNameProvider = container as? DisplayNameProvider {
cell.label?.text = displayNameProvider.nameForDisplay
}
if let compContainer = initialContainer, container === compContainer {
cell.accessoryType = .checkmark
} else {
cell.accessoryType = .none
}
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let container = containers[indexPath.row]
if let account = container as? Account, account.behaviors.contains(.disallowFeedInRootFolder) {
tableView.selectRow(at: nil, animated: false, scrollPosition: .none)
} else {
let cell = tableView.cellForRow(at: indexPath)
cell?.accessoryType = .checkmark
delegate?.didSelect(container: container)
dismiss()
}
}
// MARK: Actions
@IBAction func cancel(_ sender: Any) {
dismiss()
}
}
private extension AddFeedFolderViewController {
func dismiss() {
dismiss(animated: true)
}
}

View File

@@ -0,0 +1,22 @@
//
// AddFeedSelectFolderTableViewCell.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 12/8/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
final class AddFeedSelectFolderTableViewCell: VibrantTableViewCell {
@IBOutlet weak var folderLabel: UILabel!
@IBOutlet weak var detailLabel: UILabel!
override func updateVibrancy(animated: Bool) {
super.updateVibrancy(animated: animated)
updateLabelVibrancy(folderLabel, color: labelColor, animated: animated)
updateLabelVibrancy(detailLabel, color: labelColor, animated: animated)
}
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17147" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17120"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="AddFeedSelectFolderTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="347" height="46"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Folder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xCU-fd-wms">
<rect key="frame" x="20" y="12.5" width="49" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Detail" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="jCz-VR-Elr">
<rect key="frame" x="283" y="12.5" width="44" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="xCU-fd-wms" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="KCG-KB-QVx"/>
<constraint firstItem="jCz-VR-Elr" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="ZEI-9G-SpU"/>
<constraint firstAttribute="trailing" secondItem="jCz-VR-Elr" secondAttribute="trailing" constant="20" symbolic="YES" id="rCo-rg-mRd"/>
<constraint firstItem="xCU-fd-wms" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="rZa-T7-Dy4"/>
<constraint firstItem="jCz-VR-Elr" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="xCU-fd-wms" secondAttribute="trailing" constant="8" id="yWW-mq-p2A"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="detailLabel" destination="jCz-VR-Elr" id="glk-ta-9oJ"/>
<outlet property="folderLabel" destination="xCU-fd-wms" id="E0s-HB-ldr"/>
</connections>
<point key="canvasLocation" x="56" y="-164"/>
</view>
</objects>
</document>

View File

@@ -0,0 +1,195 @@
//
// AddFeedViewController.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/16/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import Account
import RSCore
import RSTree
import Parser
final class AddFeedViewController: UITableViewController {
@IBOutlet weak var activityIndicator: UIActivityIndicatorView!
@IBOutlet weak var addButton: UIBarButtonItem!
@IBOutlet weak var urlTextField: UITextField!
@IBOutlet weak var urlTextFieldToSuperViewConstraint: NSLayoutConstraint!
@IBOutlet weak var nameTextField: UITextField!
static let preferredContentSizeForFormSheetDisplay = CGSize(width: 460.0, height: 400.0)
private var folderLabel = ""
private var userCancelled = false
var initialFeed: String?
var initialFeedName: String?
var container: Container?
override func viewDidLoad() {
super.viewDidLoad()
activityIndicator.isHidden = true
activityIndicator.color = .label
if initialFeed == nil, let urlString = UIPasteboard.general.string {
if urlString.mayBeURL {
initialFeed = urlString.normalizedURL
}
}
urlTextField.autocorrectionType = .no
urlTextField.autocapitalizationType = .none
urlTextField.text = initialFeed
urlTextField.delegate = self
if initialFeed != nil {
addButton.isEnabled = true
}
nameTextField.text = initialFeedName
nameTextField.delegate = self
if let defaultContainer = AddFeedDefaultContainer.defaultContainer {
container = defaultContainer
} else {
addButton.isEnabled = false
}
updateFolderLabel()
tableView.register(UINib(nibName: "AddFeedSelectFolderTableViewCell", bundle: nil), forCellReuseIdentifier: "AddFeedSelectFolderTableViewCell")
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: urlTextField)
if initialFeed == nil {
urlTextField.becomeFirstResponder()
}
}
@IBAction func cancel(_ sender: Any) {
userCancelled = true
dismiss(animated: true)
}
@IBAction func add(_ sender: Any) {
let urlString = urlTextField.text ?? ""
let normalizedURLString = urlString.normalizedURL
guard !normalizedURLString.isEmpty, let url = URL(string: normalizedURLString) else {
return
}
guard let container = container else { return }
var account: Account?
if let containerAccount = container as? Account {
account = containerAccount
} else if let containerFolder = container as? Folder, let containerAccount = containerFolder.account {
account = containerAccount
}
if account!.hasFeed(withURL: url.absoluteString) {
presentError(AccountError.createErrorAlreadySubscribed)
return
}
addButton.isEnabled = false
activityIndicator.isHidden = false
activityIndicator.startAnimating()
let feedName = (nameTextField.text?.isEmpty ?? true) ? nil : nameTextField.text
BatchUpdate.shared.start()
account!.createFeed(url: url.absoluteString, name: feedName, container: container, validateFeed: true) { result in
BatchUpdate.shared.end()
switch result {
case .success(let feed):
self.dismiss(animated: true)
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.feed: feed])
case .failure(let error):
self.addButton.isEnabled = true
self.activityIndicator.isHidden = true
self.activityIndicator.stopAnimating()
self.presentError(error)
}
}
}
@objc func textDidChange(_ note: Notification) {
updateUI()
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if indexPath.row == 2 {
let cell = tableView.dequeueReusableCell(withIdentifier: "AddFeedSelectFolderTableViewCell", for: indexPath) as? AddFeedSelectFolderTableViewCell
cell!.detailLabel.text = folderLabel
return cell!
} else {
return super.tableView(tableView, cellForRowAt: indexPath)
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if indexPath.row == 2 {
let navController = UIStoryboard.add.instantiateViewController(withIdentifier: "AddFeedFolderNavViewController") as! UINavigationController
navController.modalPresentationStyle = .currentContext
let folderViewController = navController.topViewController as! AddFeedFolderViewController
folderViewController.delegate = self
folderViewController.initialContainer = container
present(navController, animated: true)
}
}
}
// MARK: AddFeedFolderViewControllerDelegate
extension AddFeedViewController: AddFeedFolderViewControllerDelegate {
func didSelect(container: Container) {
self.container = container
updateFolderLabel()
AddFeedDefaultContainer.saveDefaultContainer(container)
}
}
// MARK: UITextFieldDelegate
extension AddFeedViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
// MARK: Private
private extension AddFeedViewController {
func updateUI() {
addButton.isEnabled = (urlTextField.text?.mayBeURL ?? false)
}
func updateFolderLabel() {
if let containerName = (container as? DisplayNameProvider)?.nameForDisplay {
if container is Folder {
folderLabel = "\(container?.account?.nameForDisplay ?? "") / \(containerName)"
} else {
folderLabel = containerName
}
tableView.reloadData()
}
}
}

View File

@@ -0,0 +1,134 @@
//
// AddFolderViewController.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/16/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import Account
import RSCore
final class AddFolderViewController: UITableViewController {
@IBOutlet private weak var addButton: UIBarButtonItem!
@IBOutlet private weak var nameTextField: UITextField!
@IBOutlet private weak var accountLabel: UILabel!
@IBOutlet private weak var accountPickerView: UIPickerView!
static let preferredContentSizeForFormSheetDisplay = CGSize(width: 460.0, height: 400.0)
private var shouldDisplayPicker: Bool {
return accounts.count > 1
}
private var accounts: [Account]! {
didSet {
if let predefinedAccount = accounts.first(where: { $0.accountID == AppDefaults.addFolderAccountID }) {
selectedAccount = predefinedAccount
} else {
selectedAccount = accounts[0]
}
}
}
private var selectedAccount: Account! {
didSet {
guard selectedAccount != oldValue else { return }
accountLabel.text = selectedAccount.flatMap { ($0 as DisplayNameProvider).nameForDisplay }
}
}
override func viewDidLoad() {
super.viewDidLoad()
accounts = AccountManager.shared
.sortedActiveAccounts
.filter { !$0.behaviors.contains(.disallowFolderManagement) }
nameTextField.delegate = self
if shouldDisplayPicker {
accountPickerView.dataSource = self
accountPickerView.delegate = self
if let index = accounts.firstIndex(of: selectedAccount) {
accountPickerView.selectRow(index, inComponent: 0, animated: false)
}
} else {
accountPickerView.isHidden = true
}
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: nameTextField)
nameTextField.becomeFirstResponder()
}
private func didSelect(_ account: Account) {
AppDefaults.addFolderAccountID = account.accountID
selectedAccount = account
}
@IBAction func cancel(_ sender: Any) {
dismiss(animated: true)
}
@IBAction func add(_ sender: Any) {
guard let folderName = nameTextField.text else {
return
}
selectedAccount.addFolder(folderName) { result in
switch result {
case .success:
self.dismiss(animated: true)
case .failure(let error):
self.presentError(error)
self.dismiss(animated: true)
}
}
}
@objc func textDidChange(_ note: Notification) {
addButton.isEnabled = !(nameTextField.text?.isEmpty ?? false)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let defaultNumberOfRows = super.tableView(tableView, numberOfRowsInSection: section)
if section == 1 && !shouldDisplayPicker {
return defaultNumberOfRows - 1
}
return defaultNumberOfRows
}
}
extension AddFolderViewController: UIPickerViewDataSource, UIPickerViewDelegate {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 1
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return accounts.count
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
return (accounts[row] as DisplayNameProvider).nameForDisplay
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
didSelect(accounts[row])
}
}
extension AddFolderViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}

View File

@@ -0,0 +1,31 @@
//
// SelectComboTableViewCell.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 4/23/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
final class SelectComboTableViewCell: VibrantTableViewCell {
@IBOutlet weak var icon: UIImageView!
@IBOutlet weak var label: UILabel!
override func updateVibrancy(animated: Bool) {
super.updateVibrancy(animated: animated)
let iconTintColor = isHighlighted || isSelected ? AppColor.vibrantText : UIColor.label
if animated {
UIView.animate(withDuration: Self.duration) {
self.icon.tintColor = iconTintColor
}
} else {
self.icon.tintColor = iconTintColor
}
updateLabelVibrancy(label, color: labelColor, animated: animated)
}
}

View File

@@ -0,0 +1,96 @@
//
// ArticleExtractorButton.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/24/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
enum ArticleExtractorButtonState {
case error
case animated
case on
case off
}
final class ArticleExtractorButton: UIButton {
private var animatedLayer: CALayer?
var buttonState: ArticleExtractorButtonState = .off {
didSet {
if buttonState != oldValue {
switch buttonState {
case .error:
stripAnimatedSublayer()
setImage(AppImage.articleExtractorError, for: .normal)
case .animated:
setImage(nil, for: .normal)
setNeedsLayout()
case .on:
stripAnimatedSublayer()
setImage(AppImage.articleExtractorOn, for: .normal)
case .off:
stripAnimatedSublayer()
setImage(AppImage.articleExtractorOff, for: .normal)
}
}
}
}
override var accessibilityLabel: String? {
get {
switch buttonState {
case .error:
return NSLocalizedString("Error - Reader View", comment: "Error - Reader View")
case .animated:
return NSLocalizedString("Processing - Reader View", comment: "Processing - Reader View")
case .on:
return NSLocalizedString("Selected - Reader View", comment: "Selected - Reader View")
case .off:
return NSLocalizedString("Reader View", comment: "Reader View")
}
}
set {
super.accessibilityLabel = newValue
}
}
override func layoutSubviews() {
super.layoutSubviews()
guard case .animated = buttonState else {
return
}
stripAnimatedSublayer()
addAnimatedSublayer(to: layer)
}
private func stripAnimatedSublayer() {
animatedLayer?.removeFromSuperlayer()
}
private func addAnimatedSublayer(to hostedLayer: CALayer) {
let image1 = AppImage.articleExtractorOffTinted.cgImage!
let image2 = AppImage.articleExtractorOnTinted.cgImage!
let images = [image1, image2, image1]
animatedLayer = CALayer()
let imageSize = AppImage.articleExtractorOff.size
animatedLayer!.bounds = CGRect(x: 0, y: 0, width: imageSize.width, height: imageSize.height)
animatedLayer!.position = CGPoint(x: bounds.midX, y: bounds.midY)
hostedLayer.addSublayer(animatedLayer!)
let animation = CAKeyframeAnimation(keyPath: "contents")
animation.calculationMode = CAAnimationCalculationMode.linear
animation.keyTimes = [0, 0.5, 1]
animation.duration = 2
animation.values = images as [Any]
animation.repeatCount = HUGE
animatedLayer!.add(animation, forKey: "contents")
}
}

View File

@@ -0,0 +1,175 @@
//
// ArticleSearchBar.swift
// NetNewsWire
//
// Created by Brian Sanders on 5/8/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
@objc protocol SearchBarDelegate: NSObjectProtocol {
@objc optional func nextWasPressed(_ searchBar: ArticleSearchBar)
@objc optional func previousWasPressed(_ searchBar: ArticleSearchBar)
@objc optional func doneWasPressed(_ searchBar: ArticleSearchBar)
@objc optional func searchBar(_ searchBar: ArticleSearchBar, textDidChange: String)
}
@IBDesignable final class ArticleSearchBar: UIStackView {
var searchField: UISearchTextField!
var nextButton: UIButton!
var prevButton: UIButton!
var background: UIView!
weak private var resultsLabel: UILabel!
var resultsCount: UInt = 0 {
didSet {
updateUI()
}
}
var selectedResult: UInt = 1 {
didSet {
updateUI()
}
}
weak var delegate: SearchBarDelegate?
override var keyCommands: [UIKeyCommand]? {
return [UIKeyCommand(title: "Exit Find", action: #selector(donePressed(_:)), input: UIKeyCommand.inputEscape)]
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
layer.backgroundColor = UIColor(named: "barBackgroundColor")?.cgColor ?? UIColor.white.cgColor
isOpaque = true
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: searchField)
}
private func updateUI() {
if resultsCount > 0 {
let format = NSLocalizedString("%d of %d", comment: "Results selection and count")
resultsLabel.text = String.localizedStringWithFormat(format, selectedResult, resultsCount)
} else {
resultsLabel.text = NSLocalizedString("No results", comment: "No results")
}
nextButton.isEnabled = selectedResult < resultsCount
prevButton.isEnabled = resultsCount > 0 && selectedResult > 1
}
@discardableResult override func becomeFirstResponder() -> Bool {
searchField.becomeFirstResponder()
}
@discardableResult override func resignFirstResponder() -> Bool {
searchField.resignFirstResponder()
}
override var isFirstResponder: Bool {
searchField.isFirstResponder
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
private extension ArticleSearchBar {
func commonInit() {
isLayoutMarginsRelativeArrangement = true
alignment = .center
spacing = 8
layoutMargins.left = 8
layoutMargins.right = 8
background = UIView(frame: bounds)
background.backgroundColor = .systemGray5
background.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(background)
let doneButton = UIButton()
doneButton.setTitle(NSLocalizedString("Done", comment: "Done"), for: .normal)
doneButton.setTitleColor(UIColor.label, for: .normal)
doneButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
doneButton.isAccessibilityElement = true
doneButton.addTarget(self, action: #selector(donePressed), for: .touchUpInside)
doneButton.isEnabled = true
addArrangedSubview(doneButton)
let resultsLabel = UILabel()
searchField = UISearchTextField()
searchField.autocapitalizationType = .none
searchField.autocorrectionType = .no
searchField.returnKeyType = .search
searchField.delegate = self
resultsLabel.font = .systemFont(ofSize: UIFont.smallSystemFontSize)
resultsLabel.textColor = .secondaryLabel
resultsLabel.text = ""
resultsLabel.textAlignment = .right
resultsLabel.adjustsFontSizeToFitWidth = true
searchField.rightView = resultsLabel
searchField.rightViewMode = .always
self.resultsLabel = resultsLabel
addArrangedSubview(searchField)
prevButton = UIButton(type: .system)
prevButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
prevButton.accessibilityLabel = "Previous Result"
prevButton.isAccessibilityElement = true
prevButton.addTarget(self, action: #selector(previousPressed), for: .touchUpInside)
addArrangedSubview(prevButton)
nextButton = UIButton(type: .system)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
nextButton.accessibilityLabel = "Next Result"
nextButton.isAccessibilityElement = true
nextButton.addTarget(self, action: #selector(nextPressed), for: .touchUpInside)
addArrangedSubview(nextButton)
}
}
private extension ArticleSearchBar {
@objc func textDidChange(_ notification: Notification) {
delegate?.searchBar?(self, textDidChange: searchField.text ?? "")
if searchField.text?.isEmpty ?? true {
searchField.rightViewMode = .never
} else {
searchField.rightViewMode = .always
}
}
@objc func nextPressed() {
delegate?.nextWasPressed?(self)
}
@objc func previousPressed() {
delegate?.previousWasPressed?(self)
}
@objc func donePressed(_ _: Any? = nil) {
delegate?.doneWasPressed?(self)
}
}
extension ArticleSearchBar: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
delegate?.nextWasPressed?(self)
return false
}
}

View File

@@ -0,0 +1,510 @@
//
// ArticleViewController.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/8/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import WebKit
import Account
import Articles
import SafariServices
final class ArticleViewController: UIViewController {
struct State {
let extractedArticle: ExtractedArticle?
let isShowingExtractedArticle: Bool
let articleExtractorButtonState: ArticleExtractorButtonState
let windowScrollY: Int
}
@IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var nextArticleBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var readBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var starBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var actionBarButtonItem: UIBarButtonItem!
@IBOutlet private var searchBar: ArticleSearchBar!
@IBOutlet private var searchBarBottomConstraint: NSLayoutConstraint!
private var defaultControls: [UIBarButtonItem]?
private var pageViewController: UIPageViewController!
private var currentWebViewController: WebViewController? {
return pageViewController?.viewControllers?.first as? WebViewController
}
private var articleExtractorButton: ArticleExtractorButton = {
let button = ArticleExtractorButton(type: .system)
button.frame = CGRect(x: 0, y: 0, width: 44.0, height: 44.0)
button.setImage(AppImage.articleExtractorOff, for: .normal)
return button
}()
weak var coordinator: SceneCoordinator!
private let poppableDelegate = PoppableGestureRecognizerDelegate()
var article: Article? {
didSet {
if let controller = currentWebViewController, controller.article != article {
controller.setArticle(article)
DispatchQueue.main.async {
// You have to set the view controller to clear out the UIPageViewController child controller cache.
// You also have to do it in an async call or you will get a strange assertion error.
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
}
}
updateUI()
}
}
var restoreScrollPosition: (isShowingExtractedArticle: Bool, articleWindowScrollY: Int)? {
didSet {
if let rsp = restoreScrollPosition {
currentWebViewController?.setScrollPosition(isShowingExtractedArticle: rsp.isShowingExtractedArticle, articleWindowScrollY: rsp.articleWindowScrollY)
}
}
}
var currentState: State? {
guard let controller = currentWebViewController else { return nil}
return State(extractedArticle: controller.extractedArticle,
isShowingExtractedArticle: controller.isShowingExtractedArticle,
articleExtractorButtonState: controller.articleExtractorButtonState,
windowScrollY: controller.windowScrollY)
}
var restoreState: State?
private let keyboardManager = KeyboardManager(type: .detail)
override var keyCommands: [UIKeyCommand]? {
return keyboardManager.keyCommands
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
let fullScreenTapZone = UIView()
NSLayoutConstraint.activate([
fullScreenTapZone.widthAnchor.constraint(equalToConstant: 150),
fullScreenTapZone.heightAnchor.constraint(equalToConstant: 44)
])
fullScreenTapZone.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNavigationBar)))
navigationItem.titleView = fullScreenTapZone
articleExtractorButton.addTarget(self, action: #selector(toggleArticleExtractor(_:)), for: .touchUpInside)
toolbarItems?.insert(UIBarButtonItem(customView: articleExtractorButton), at: 6)
if let parentNavController = navigationController?.parent as? UINavigationController {
poppableDelegate.navigationController = parentNavController
parentNavController.interactivePopGestureRecognizer?.delegate = poppableDelegate
}
pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
pageViewController.delegate = self
pageViewController.dataSource = self
// This code is to disallow paging if we scroll from the left edge. If this code is removed
// PoppableGestureRecognizerDelegate will allow us to both navigate back and page back at the
// same time. That is really weird when it happens.
let panGestureRecognizer = UIPanGestureRecognizer()
panGestureRecognizer.delegate = self
pageViewController.scrollViewInsidePageControl?.addGestureRecognizer(panGestureRecognizer)
pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageViewController.view)
addChild(pageViewController!)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor),
view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor),
view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
])
let controller: WebViewController
if let state = restoreState {
controller = createWebViewController(article, updateView: false)
controller.extractedArticle = state.extractedArticle
controller.isShowingExtractedArticle = state.isShowingExtractedArticle
controller.articleExtractorButtonState = state.articleExtractorButtonState
controller.windowScrollY = state.windowScrollY
} else {
controller = createWebViewController(article, updateView: true)
}
if let rsp = restoreScrollPosition {
controller.setScrollPosition(isShowingExtractedArticle: rsp.isShowingExtractedArticle, articleWindowScrollY: rsp.articleWindowScrollY)
}
articleExtractorButton.buttonState = controller.articleExtractorButtonState
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
if AppDefaults.logicalArticleFullscreenEnabled {
controller.hideBars()
}
// Search bar
searchBar.translatesAutoresizingMaskIntoConstraints = false
NotificationCenter.default.addObserver(self, selector: #selector(beginFind(_:)), name: .FindInArticle, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(endFind(_:)), name: .EndFindInArticle, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIWindow.keyboardWillChangeFrameNotification, object: nil)
searchBar.delegate = self
view.bringSubviewToFront(searchBar)
updateUI()
}
override func viewWillAppear(_ animated: Bool) {
let hideToolbars = AppDefaults.logicalArticleFullscreenEnabled
if hideToolbars {
currentWebViewController?.hideBars()
} else {
currentWebViewController?.showBars()
}
super.viewWillAppear(animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)
coordinator.isArticleViewControllerPending = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if searchBar != nil && !searchBar.isHidden {
endFind()
}
}
override func viewSafeAreaInsetsDidChange() {
// This will animate if the show/hide bars animation is happening.
view.layoutIfNeeded()
}
override func willTransition(to newCollection: UITraitCollection, with coordinator: any UIViewControllerTransitionCoordinator) {
// We only want to show bars when rotating to horizontalSizeClass == .regular
// (i.e., big) iPhones to resolve crash #4483.
if UIDevice.current.userInterfaceIdiom == .phone && newCollection.horizontalSizeClass == .regular {
currentWebViewController?.showBars()
}
}
func updateUI() {
guard let article = article else {
articleExtractorButton.isEnabled = false
nextUnreadBarButtonItem.isEnabled = false
prevArticleBarButtonItem.isEnabled = false
nextArticleBarButtonItem.isEnabled = false
readBarButtonItem.isEnabled = false
starBarButtonItem.isEnabled = false
actionBarButtonItem.isEnabled = false
return
}
nextUnreadBarButtonItem.isEnabled = coordinator.isAnyUnreadAvailable
prevArticleBarButtonItem.isEnabled = coordinator.isPrevArticleAvailable
nextArticleBarButtonItem.isEnabled = coordinator.isNextArticleAvailable
readBarButtonItem.isEnabled = true
starBarButtonItem.isEnabled = true
let permalinkPresent = article.preferredLink != nil
articleExtractorButton.isEnabled = permalinkPresent && !AppDefaults.isDeveloperBuild
actionBarButtonItem.isEnabled = permalinkPresent
if article.status.read {
readBarButtonItem.image = AppImage.circleOpen
readBarButtonItem.isEnabled = article.isAvailableToMarkUnread
readBarButtonItem.accLabelText = NSLocalizedString("Mark Article Unread", comment: "Mark Article Unread")
} else {
readBarButtonItem.image = AppImage.circleClosed
readBarButtonItem.isEnabled = true
readBarButtonItem.accLabelText = NSLocalizedString("Selected - Mark Article Unread", comment: "Selected - Mark Article Unread")
}
if article.status.starred {
starBarButtonItem.image = AppImage.starClosed
starBarButtonItem.accLabelText = NSLocalizedString("Selected - Star Article", comment: "Selected - Star Article")
} else {
starBarButtonItem.image = AppImage.starOpen
starBarButtonItem.accLabelText = NSLocalizedString("Star Article", comment: "Star Article")
}
}
// MARK: Notifications
@objc dynamic func unreadCountDidChange(_ notification: Notification) {
updateUI()
}
@objc func statusesDidChange(_ note: Notification) {
guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
return
}
guard let article = article else {
return
}
if articleIDs.contains(article.articleID) {
updateUI()
}
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
currentWebViewController?.fullReload()
}
@objc func willEnterForeground(_ note: Notification) {
// The toolbar will come back on you if you don't hide it again
if AppDefaults.logicalArticleFullscreenEnabled {
currentWebViewController?.hideBars()
}
}
// MARK: Actions
@objc func didTapNavigationBar() {
currentWebViewController?.hideBars()
}
@objc func showBars(_ sender: Any) {
currentWebViewController?.showBars()
}
@IBAction func toggleArticleExtractor(_ sender: Any) {
currentWebViewController?.toggleArticleExtractor()
}
@IBAction func nextUnread(_ sender: Any) {
coordinator.selectNextUnread()
}
@IBAction func prevArticle(_ sender: Any) {
coordinator.selectPrevArticle()
}
@IBAction func nextArticle(_ sender: Any) {
coordinator.selectNextArticle()
}
@IBAction func toggleRead(_ sender: Any) {
coordinator.toggleReadForCurrentArticle()
}
@IBAction func toggleStar(_ sender: Any) {
coordinator.toggleStarredForCurrentArticle()
}
@IBAction func showActivityDialog(_ sender: Any) {
currentWebViewController?.showActivityDialog(popOverBarButtonItem: actionBarButtonItem)
}
@objc func toggleReaderView(_ sender: Any?) {
currentWebViewController?.toggleArticleExtractor()
}
// MARK: Keyboard Shortcuts
@objc func navigateToTimeline(_ sender: Any?) {
coordinator.navigateToTimeline()
}
// MARK: API
func focus() {
currentWebViewController?.focus()
}
func canScrollDown() -> Bool {
return currentWebViewController?.canScrollDown() ?? false
}
func canScrollUp() -> Bool {
return currentWebViewController?.canScrollUp() ?? false
}
func scrollPageDown() {
currentWebViewController?.scrollPageDown()
}
func scrollPageUp() {
currentWebViewController?.scrollPageUp()
}
func stopArticleExtractorIfProcessing() {
currentWebViewController?.stopArticleExtractorIfProcessing()
}
func openInAppBrowser() {
currentWebViewController?.openInAppBrowser()
}
func setScrollPosition(isShowingExtractedArticle: Bool, articleWindowScrollY: Int) {
currentWebViewController?.setScrollPosition(isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY)
}
}
// MARK: Find in Article
public extension Notification.Name {
static let FindInArticle = Notification.Name("FindInArticle")
static let EndFindInArticle = Notification.Name("EndFindInArticle")
}
extension ArticleViewController: SearchBarDelegate {
func searchBar(_ searchBar: ArticleSearchBar, textDidChange searchText: String) {
currentWebViewController?.searchText(searchText) { found in
searchBar.resultsCount = found.count
if let index = found.index {
searchBar.selectedResult = index + 1
}
}
}
func doneWasPressed(_ searchBar: ArticleSearchBar) {
NotificationCenter.default.post(name: .EndFindInArticle, object: nil)
}
func nextWasPressed(_ searchBar: ArticleSearchBar) {
if searchBar.selectedResult < searchBar.resultsCount {
currentWebViewController?.selectNextSearchResult()
searchBar.selectedResult += 1
}
}
func previousWasPressed(_ searchBar: ArticleSearchBar) {
if searchBar.selectedResult > 1 {
currentWebViewController?.selectPreviousSearchResult()
searchBar.selectedResult -= 1
}
}
}
extension ArticleViewController {
@objc func beginFind(_ _: Any? = nil) {
searchBar.isHidden = false
navigationController?.setToolbarHidden(true, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = searchBar.frame.height
searchBar.becomeFirstResponder()
}
@objc func endFind(_ _: Any? = nil) {
searchBar.resignFirstResponder()
searchBar.isHidden = true
navigationController?.setToolbarHidden(false, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = 0
currentWebViewController?.endSearch()
}
@objc func keyboardWillChangeFrame(_ notification: Notification) {
if !searchBar.isHidden,
let duration = notification.userInfo?[UIWindow.keyboardAnimationDurationUserInfoKey] as? Double,
let curveRaw = notification.userInfo?[UIWindow.keyboardAnimationCurveUserInfoKey] as? UInt,
let frame = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect {
let curve = UIView.AnimationOptions(rawValue: curveRaw)
let newHeight = view.safeAreaLayoutGuide.layoutFrame.maxY - frame.minY
currentWebViewController?.additionalSafeAreaInsets.bottom = newHeight + searchBar.frame.height + 10
self.searchBarBottomConstraint.constant = newHeight
UIView.animate(withDuration: duration, delay: 0, options: curve, animations: {
self.view.layoutIfNeeded()
})
}
}
}
// MARK: WebViewControllerDelegate
extension ArticleViewController: WebViewControllerDelegate {
func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) {
if webViewController === currentWebViewController {
articleExtractorButton.buttonState = buttonState
}
}
}
// MARK: UIPageViewControllerDataSource
extension ArticleViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = coordinator.findPrevArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = coordinator.findNextArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
}
// MARK: UIPageViewControllerDelegate
extension ArticleViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard finished, completed else { return }
guard let article = currentWebViewController?.article else { return }
coordinator.selectArticle(article, animations: [.select, .scroll, .navigation])
articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
previousViewControllers.compactMap({ $0 as? WebViewController }).forEach({ $0.stopWebViewActivity() })
}
}
// MARK: UIGestureRecognizerDelegate
extension ArticleViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let point = gestureRecognizer.location(in: nil)
if point.x > 40 {
return true
}
return false
}
}
// MARK: Private
private extension ArticleViewController {
func createWebViewController(_ article: Article?, updateView: Bool = true) -> WebViewController {
let controller = WebViewController()
controller.coordinator = coordinator
controller.delegate = self
controller.setArticle(article, updateView: updateView)
return controller
}
}

View File

@@ -0,0 +1,77 @@
//
// ContextMenuPreviewViewController.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/25/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import Articles
/// Used in the WebView when in full screen mode.
final class ContextMenuPreviewViewController: UIViewController {
@IBOutlet weak var blogNameLabel: UILabel!
@IBOutlet weak var blogAuthorLabel: UILabel!
@IBOutlet weak var articleTitleLabel: UILabel!
@IBOutlet weak var dateTimeLabel: UILabel!
var article: Article?
init(article: Article?) {
self.article = article
super.init(nibName: "ContextMenuPreviewViewController", bundle: nil)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func viewDidLoad() {
super.viewDidLoad()
blogNameLabel.text = article?.feed?.nameForDisplay ?? ""
blogAuthorLabel.text = article?.byline()
articleTitleLabel.text = article?.title ?? ""
let icon = IconView()
icon.iconImage = article?.iconImage()
icon.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(icon)
NSLayoutConstraint.activate([
icon.widthAnchor.constraint(equalToConstant: 48),
icon.heightAnchor.constraint(equalToConstant: 48),
icon.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
icon.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20)
])
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
dateFormatter.timeStyle = .medium
if let article {
dateTimeLabel.text = dateFormatter.string(from: article.logicalDatePublished)
}
// When in landscape the context menu preview will force this controller into a tiny
// view space. If it is documented anywhere what that is, I haven't found it. This
// set of magic numbers is what I worked out by testing a variety of phones.
let width: CGFloat
let heightPadding: CGFloat
if view.bounds.width > view.bounds.height {
width = 260
heightPadding = 16
view.widthAnchor.constraint(equalToConstant: width).isActive = true
} else {
width = view.bounds.width
heightPadding = 8
}
view.setNeedsLayout()
view.layoutIfNeeded()
preferredContentSize = CGSize(width: width, height: dateTimeLabel.frame.maxY + heightPadding)
}
}

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ContextMenuPreviewViewController" customModule="NetNewsWire" customModuleProvider="target">
<connections>
<outlet property="articleTitleLabel" destination="euU-Ij-5LS" id="SaE-2x-Lmt"/>
<outlet property="blogAuthorLabel" destination="yxD-bn-7rJ" id="e6N-4a-9gZ"/>
<outlet property="blogNameLabel" destination="VwJ-Ji-WmN" id="rvk-Ef-eXK"/>
<outlet property="dateTimeLabel" destination="tZE-CV-RS5" id="hvo-tH-m4w"/>
<outlet property="view" destination="reY-47-9dn" id="Uul-on-hM9"/>
</connections>
</placeholder>
<view contentMode="scaleToFill" id="reY-47-9dn">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Blog Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="VwJ-Ji-WmN">
<rect key="frame" x="20" y="8.0000000000000018" width="87" height="20.666666666666671"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<color key="textColor" name="primaryAccentColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Blog Author" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yxD-bn-7rJ">
<rect key="frame" x="20" y="36.666666666666664" width="90" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Article Title" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="euU-Ij-5LS">
<rect key="frame" x="20" y="74.666666666666671" width="136" height="33.666666666666671"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tZE-CV-RS5">
<rect key="frame" x="20" y="116.33333333333333" width="44" height="20.333333333333329"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Xxd-ws-vsN">
<rect key="frame" x="325" y="8" width="48" height="48"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="width" constant="48" id="3zG-fk-K1j"/>
<constraint firstAttribute="height" constant="48" id="LUF-bb-x6X"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="PEP-UU-rG9">
<rect key="frame" x="20" y="65.666666666666671" width="353" height="1"/>
<color key="backgroundColor" systemColor="separatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="1" id="K9J-bi-mdi"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="Hb5-Na-kgr"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="tZE-CV-RS5" secondAttribute="bottom" constant="8" id="3rq-hS-dk8"/>
<constraint firstItem="yxD-bn-7rJ" firstAttribute="top" secondItem="VwJ-Ji-WmN" secondAttribute="bottom" constant="8" id="5xH-dU-ncD"/>
<constraint firstItem="PEP-UU-rG9" firstAttribute="top" secondItem="yxD-bn-7rJ" secondAttribute="bottom" constant="8" id="HaH-jK-jid"/>
<constraint firstItem="euU-Ij-5LS" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="OHK-XM-nZh"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="euU-Ij-5LS" secondAttribute="trailing" constant="20" id="ShD-8C-ek1"/>
<constraint firstItem="Xxd-ws-vsN" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="yxD-bn-7rJ" secondAttribute="trailing" constant="8" id="U9x-ti-Tu2"/>
<constraint firstItem="VwJ-Ji-WmN" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="VWZ-aZ-O9Q"/>
<constraint firstItem="tZE-CV-RS5" firstAttribute="top" secondItem="euU-Ij-5LS" secondAttribute="bottom" constant="8" id="YKK-UR-dnH"/>
<constraint firstItem="Xxd-ws-vsN" firstAttribute="top" secondItem="reY-47-9dn" secondAttribute="top" constant="8" id="ZyL-ai-qvZ"/>
<constraint firstAttribute="trailing" secondItem="PEP-UU-rG9" secondAttribute="trailing" constant="20" id="eKG-67-YFm"/>
<constraint firstItem="PEP-UU-rG9" firstAttribute="leading" secondItem="reY-47-9dn" secondAttribute="leading" constant="20" id="ebf-0Y-flG"/>
<constraint firstItem="VwJ-Ji-WmN" firstAttribute="top" secondItem="reY-47-9dn" secondAttribute="top" constant="8" id="ecl-RK-Ffb"/>
<constraint firstItem="Hb5-Na-kgr" firstAttribute="trailing" secondItem="Xxd-ws-vsN" secondAttribute="trailing" constant="20" id="kT7-Pv-6je"/>
<constraint firstItem="yxD-bn-7rJ" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="sqt-wW-SeY"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="tZE-CV-RS5" secondAttribute="trailing" constant="20" id="xac-fb-QAh"/>
<constraint firstItem="Xxd-ws-vsN" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="VwJ-Ji-WmN" secondAttribute="trailing" constant="8" id="xgp-8f-SqT"/>
<constraint firstItem="PEP-UU-rG9" firstAttribute="top" relation="greaterThanOrEqual" secondItem="Xxd-ws-vsN" secondAttribute="bottom" constant="8" id="yzA-bn-qZ1"/>
<constraint firstItem="euU-Ij-5LS" firstAttribute="top" secondItem="PEP-UU-rG9" secondAttribute="bottom" constant="8" id="zq0-Jn-mdy"/>
<constraint firstItem="tZE-CV-RS5" firstAttribute="leading" secondItem="Hb5-Na-kgr" secondAttribute="leading" constant="20" id="zs5-jN-xgs"/>
</constraints>
<point key="canvasLocation" x="3292" y="-1399"/>
</view>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
</objects>
<resources>
<namedColor name="primaryAccentColor">
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<systemColor name="separatorColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@@ -0,0 +1,40 @@
//
// FindInArticleActivity.swift
// NetNewsWire-iOS
//
// Created by Brian Sanders on 5/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
final class FindInArticleActivity: UIActivity {
override var activityTitle: String? {
NSLocalizedString("Find in Article", comment: "Find in Article")
}
override var activityType: UIActivity.ActivityType? {
UIActivity.ActivityType(rawValue: "com.ranchero.NetNewsWire.find")
}
override var activityImage: UIImage? {
UIImage(systemName: "magnifyingglass", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
}
override static var activityCategory: UIActivity.Category {
.action
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
true
}
override func prepare(withActivityItems activityItems: [Any]) {
}
override func perform() {
NotificationCenter.default.post(Notification(name: .FindInArticle))
activityDidFinish(true)
}
}

View File

@@ -0,0 +1,358 @@
//
// ImageScrollView.swift
// Beauty
//
// Created by Nguyen Cong Huy on 1/19/16.
// Copyright © 2016 Nguyen Cong Huy. All rights reserved.
//
import UIKit
@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate {
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView)
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView)
}
open class ImageScrollView: UIScrollView {
@objc public enum ScaleMode: Int {
case aspectFill
case aspectFit
case widthFill
case heightFill
}
@objc public enum Offset: Int {
case beginning
case center
}
static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2
@objc open var imageContentMode: ScaleMode = .widthFill
@objc open var initialOffset: Offset = .beginning
@objc public private(set) var zoomView: UIImageView?
@objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate?
var imageSize: CGSize = CGSize.zero
private var pointToCenterAfterResize: CGPoint = CGPoint.zero
private var scaleToRestoreAfterResize: CGFloat = 1.0
var maxScaleFromMinScale: CGFloat = 3.0
var zoomedFrame: CGRect {
return zoomView?.frame ?? CGRect.zero
}
override open var frame: CGRect {
willSet {
if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
prepareToResize()
}
}
didSet {
if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
recoverFromResizing()
}
}
}
override public init(frame: CGRect) {
super.init(frame: frame)
initialize()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
private func initialize() {
showsVerticalScrollIndicator = false
showsHorizontalScrollIndicator = false
bouncesZoom = true
decelerationRate = UIScrollView.DecelerationRate.fast
delegate = self
}
@objc public func adjustFrameToCenter() {
guard let unwrappedZoomView = zoomView else {
return
}
var frameToCenter = unwrappedZoomView.frame
// center horizontally
if frameToCenter.size.width < bounds.width {
frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2
} else {
frameToCenter.origin.x = 0
}
// center vertically
if frameToCenter.size.height < bounds.height {
frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2
} else {
frameToCenter.origin.y = 0
}
unwrappedZoomView.frame = frameToCenter
}
private func prepareToResize() {
let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY)
pointToCenterAfterResize = convert(boundsCenter, to: zoomView)
scaleToRestoreAfterResize = zoomScale
// If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum
// allowable scale when the scale is restored.
if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) {
scaleToRestoreAfterResize = 0
}
}
private func recoverFromResizing() {
setMaxMinZoomScalesForCurrentBounds()
// restore zoom scale, first making sure it is within the allowable range.
let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize)
zoomScale = min(maximumZoomScale, maxZoomScale)
// restore center point, first making sure it is within the allowable range.
// convert our desired center point back to our own coordinate space
let boundsCenter = convert(pointToCenterAfterResize, to: zoomView)
// calculate the content offset that would yield that center point
var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0)
// restore offset, adjusted to be within the allowable range
let maxOffset = maximumContentOffset()
let minOffset = minimumContentOffset()
var realMaxOffset = min(maxOffset.x, offset.x)
offset.x = max(minOffset.x, realMaxOffset)
realMaxOffset = min(maxOffset.y, offset.y)
offset.y = max(minOffset.y, realMaxOffset)
contentOffset = offset
}
private func maximumContentOffset() -> CGPoint {
return CGPoint(x: contentSize.width - bounds.width, y: contentSize.height - bounds.height)
}
private func minimumContentOffset() -> CGPoint {
return CGPoint.zero
}
// MARK: - Set up
open func setup() {
var topSupperView = superview
while topSupperView?.superview != nil {
topSupperView = topSupperView?.superview
}
// Make sure views have already layout with precise frame
topSupperView?.layoutIfNeeded()
}
// MARK: - Display image
@objc open func display(image: UIImage) {
if let zoomView = zoomView {
zoomView.removeFromSuperview()
}
zoomView = UIImageView(image: image)
zoomView!.isUserInteractionEnabled = true
addSubview(zoomView!)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(doubleTapGestureRecognizer(_:)))
tapGesture.numberOfTapsRequired = 2
zoomView!.addGestureRecognizer(tapGesture)
let downSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeUpGestureRecognizer(_:)))
downSwipeGesture.direction = .down
zoomView!.addGestureRecognizer(downSwipeGesture)
let upSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeDownGestureRecognizer(_:)))
upSwipeGesture.direction = .up
zoomView!.addGestureRecognizer(upSwipeGesture)
configureImageForSize(image.size)
adjustFrameToCenter()
}
private func configureImageForSize(_ size: CGSize) {
imageSize = size
contentSize = imageSize
setMaxMinZoomScalesForCurrentBounds()
zoomScale = minimumZoomScale
switch initialOffset {
case .beginning:
contentOffset = CGPoint.zero
case .center:
let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2
let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2
switch imageContentMode {
case .aspectFit:
contentOffset = CGPoint.zero
case .aspectFill:
contentOffset = CGPoint(x: xOffset, y: yOffset)
case .heightFill:
contentOffset = CGPoint(x: xOffset, y: 0)
case .widthFill:
contentOffset = CGPoint(x: 0, y: yOffset)
}
}
}
private func setMaxMinZoomScalesForCurrentBounds() {
// calculate min/max zoomscale
let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise
let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise
var minScale: CGFloat = 1
switch imageContentMode {
case .aspectFill:
minScale = max(xScale, yScale)
case .aspectFit:
minScale = min(xScale, yScale)
case .widthFill:
minScale = xScale
case .heightFill:
minScale = yScale
}
let maxScale = maxScaleFromMinScale*minScale
// don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.)
if minScale > maxScale {
minScale = maxScale
}
maximumZoomScale = maxScale
minimumZoomScale = minScale // * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController
}
// MARK: - Gesture
@objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
// zoom out if it bigger than middle scale point. Else, zoom in
if zoomScale >= maximumZoomScale / 2.0 {
setZoomScale(minimumZoomScale, animated: true)
} else {
let center = gestureRecognizer.location(in: gestureRecognizer.view)
let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center)
zoom(to: zoomRect, animated: true)
}
}
@objc func swipeUpGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .ended {
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeUp(imageScrollView: self)
}
}
@objc func swipeDownGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .ended {
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeDown(imageScrollView: self)
}
}
private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect {
var zoomRect = CGRect.zero
// the zoom rect is in the content view's coordinates.
// at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds.
// as the zoom scale decreases, so more content is visible, the size of the rect grows.
zoomRect.size.height = frame.size.height / scale
zoomRect.size.width = frame.size.width / scale
// choose an origin so as to get the right center.
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
return zoomRect
}
open func refresh() {
if let image = zoomView?.image {
display(image: image)
}
}
open func resize() {
self.configureImageForSize(self.imageSize)
}
}
extension ImageScrollView: UIScrollViewDelegate {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidScroll?(scrollView)
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView)
}
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
}
public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView)
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView)
}
public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view)
}
public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
}
public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
return false
}
public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView)
}
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return zoomView
}
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
adjustFrameToCenter()
imageScrollViewDelegate?.scrollViewDidZoom?(scrollView)
}
}

View File

@@ -0,0 +1,110 @@
//
// ImageAnimator.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/15/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
final class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
private weak var webViewController: WebViewController?
private let duration = 0.4
var presenting = true
var originFrame: CGRect!
var maskFrame: CGRect!
var originImage: UIImage!
init(controller: WebViewController) {
self.webViewController = controller
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if presenting {
animateTransitionPresenting(using: transitionContext)
} else {
animateTransitionReturning(using: transitionContext)
}
}
private func animateTransitionPresenting(using transitionContext: UIViewControllerContextTransitioning) {
let imageView = UIImageView(image: originImage)
imageView.frame = originFrame
let fromView = transitionContext.view(forKey: .from)!
fromView.removeFromSuperview()
transitionContext.containerView.backgroundColor = AppColor.fullScreenBackground
transitionContext.containerView.addSubview(imageView)
webViewController?.hideClickedImage()
UIView.animate(
withDuration: duration,
delay: 0.0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.2,
animations: {
let imageController = transitionContext.viewController(forKey: .to) as! ImageViewController
imageView.frame = imageController.zoomedFrame
}, completion: { _ in
imageView.removeFromSuperview()
let toView = transitionContext.view(forKey: .to)!
transitionContext.containerView.addSubview(toView)
transitionContext.completeTransition(true)
})
}
private func animateTransitionReturning(using transitionContext: UIViewControllerContextTransitioning) {
let imageController = transitionContext.viewController(forKey: .from) as! ImageViewController
let imageView = UIImageView(image: originImage)
imageView.frame = imageController.zoomedFrame
let fromView = transitionContext.view(forKey: .from)!
let windowFrame = fromView.window!.frame
fromView.removeFromSuperview()
let toView = transitionContext.view(forKey: .to)!
transitionContext.containerView.addSubview(toView)
let maskingView = UIView()
let fullMaskFrame = CGRect(x: windowFrame.minX, y: maskFrame.minY, width: windowFrame.width, height: maskFrame.height)
let path = UIBezierPath(rect: fullMaskFrame)
let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath
maskingView.layer.mask = maskLayer
maskingView.addSubview(imageView)
transitionContext.containerView.addSubview(maskingView)
UIView.animate(
withDuration: duration,
delay: 0.0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.2,
animations: {
imageView.frame = self.originFrame
}, completion: { _ in
if let controller = self.webViewController {
controller.showClickedImage {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
imageView.removeFromSuperview()
transitionContext.completeTransition(true)
}
}
} else {
imageView.removeFromSuperview()
transitionContext.completeTransition(true)
}
})
}
}

View File

@@ -0,0 +1,98 @@
//
// ImageViewController.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/12/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
final class ImageViewController: UIViewController {
@IBOutlet weak var closeButton: UIButton!
@IBOutlet weak var shareButton: UIButton!
@IBOutlet weak var imageScrollView: ImageScrollView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var titleBackground: UIVisualEffectView!
@IBOutlet weak var titleLeading: NSLayoutConstraint!
@IBOutlet weak var titleTrailing: NSLayoutConstraint!
var image: UIImage!
var imageTitle: String?
var zoomedFrame: CGRect {
return imageScrollView.zoomedFrame
}
init() {
super.init(nibName: "ImageViewController", bundle: nil)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func viewDidLoad() {
super.viewDidLoad()
closeButton.imageView?.contentMode = .scaleAspectFit
closeButton.accessibilityLabel = NSLocalizedString("Close", comment: "Close")
shareButton.accessibilityLabel = NSLocalizedString("Share", comment: "Share")
imageScrollView.setup()
imageScrollView.imageScrollViewDelegate = self
imageScrollView.imageContentMode = .aspectFit
imageScrollView.initialOffset = .center
imageScrollView.display(image: image)
titleLabel.text = imageTitle ?? ""
layoutTitleLabel()
guard imageTitle != "" else {
titleBackground.removeFromSuperview()
return
}
titleBackground.layer.cornerRadius = 6
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { [weak self] _ in
self?.imageScrollView.resize()
})
}
@IBAction func share(_ sender: Any) {
guard let image = image else { return }
let activityViewController = UIActivityViewController(activityItems: [image], applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = shareButton
activityViewController.popoverPresentationController?.sourceRect = shareButton.bounds
present(activityViewController, animated: true)
}
@IBAction func done(_ sender: Any) {
dismiss(animated: true)
}
private func layoutTitleLabel() {
let width = view.frame.width
let multiplier = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(0.1) : CGFloat(0.04)
titleLeading.constant += width * multiplier
titleTrailing.constant -= width * multiplier
titleLabel.layoutIfNeeded()
}
}
// MARK: ImageScrollViewDelegate
extension ImageViewController: ImageScrollViewDelegate {
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) {
dismiss(animated: true)
}
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) {
dismiss(animated: true)
}
}

View File

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ImageViewController" customModule="NetNewsWire" customModuleProvider="target">
<connections>
<outlet property="closeButton" destination="bh5-KV-5HI" id="3gh-Y3-Kjf"/>
<outlet property="imageScrollView" destination="t42-v5-7DN" id="TR2-aK-Pz0"/>
<outlet property="shareButton" destination="3Wa-fp-kMe" id="jjN-jQ-BWP"/>
<outlet property="titleBackground" destination="rXU-KY-jBH" id="JGd-Fp-biL"/>
<outlet property="titleLabel" destination="lQ6-x9-Tcu" id="QPn-ac-kYi"/>
<outlet property="titleLeading" destination="bHE-Eq-ddT" id="WKB-C3-z0s"/>
<outlet property="titleTrailing" destination="OFG-cU-iTN" id="VyL-dz-6Ch"/>
<outlet property="view" destination="2qJ-Gw-Tlk" id="S0T-fW-KSq"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="2qJ-Gw-Tlk">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<scrollView verifyAmbiguity="off" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="t42-v5-7DN" customClass="ImageScrollView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<viewLayoutGuide key="contentLayoutGuide" id="rDi-IL-3hP"/>
<viewLayoutGuide key="frameLayoutGuide" id="Rk2-H7-hcc"/>
</scrollView>
<visualEffectView opaque="NO" clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rXU-KY-jBH">
<rect key="frame" x="-4" y="806" width="401" height="8"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Sbe-dT-bwF">
<rect key="frame" x="0.0" y="0.0" width="401" height="8"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</view>
<blurEffect style="systemUltraThinMaterial"/>
</visualEffectView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lQ6-x9-Tcu">
<rect key="frame" x="0.0" y="810" width="393" height="0.0"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="3Wa-fp-kMe">
<rect key="frame" x="341" y="59" width="44" height="44"/>
<constraints>
<constraint firstAttribute="width" constant="44" id="BG7-ht-naS"/>
<constraint firstAttribute="height" constant="44" id="cuX-WF-pUh"/>
</constraints>
<color key="tintColor" name="primaryAccentColor"/>
<state key="normal" image="square.and.arrow.up.fill" catalog="system"/>
<connections>
<action selector="share:" destination="-1" eventType="touchUpInside" id="jti-xh-2Yt"/>
</connections>
</button>
<button opaque="NO" clipsSubviews="YES" contentMode="center" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bh5-KV-5HI">
<rect key="frame" x="8" y="59" width="44" height="44"/>
<constraints>
<constraint firstAttribute="width" constant="44" id="VXi-hw-q5q"/>
<constraint firstAttribute="height" constant="44" id="yWs-vd-PHK"/>
</constraints>
<color key="tintColor" name="primaryAccentColor"/>
<state key="normal" image="multiply.circle.fill" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large" weight="regular"/>
</state>
<connections>
<action selector="done:" destination="-1" eventType="touchUpInside" id="OZB-pn-m1N"/>
</connections>
</button>
</subviews>
<viewLayoutGuide key="safeArea" id="Mwx-oc-6Cf"/>
<color key="backgroundColor" name="fullScreenBackgroundColor"/>
<constraints>
<constraint firstItem="3Wa-fp-kMe" firstAttribute="top" secondItem="Mwx-oc-6Cf" secondAttribute="top" id="3Mg-5D-jao"/>
<constraint firstItem="rXU-KY-jBH" firstAttribute="bottom" secondItem="lQ6-x9-Tcu" secondAttribute="bottom" constant="4" id="5AX-Pq-B03"/>
<constraint firstItem="lQ6-x9-Tcu" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Mwx-oc-6Cf" secondAttribute="trailing" id="OFG-cU-iTN"/>
<constraint firstItem="rXU-KY-jBH" firstAttribute="trailing" secondItem="lQ6-x9-Tcu" secondAttribute="trailing" constant="4" id="P5Y-FS-He4"/>
<constraint firstItem="rXU-KY-jBH" firstAttribute="leading" secondItem="lQ6-x9-Tcu" secondAttribute="leading" constant="-4" id="PeO-2J-lve"/>
<constraint firstItem="rXU-KY-jBH" firstAttribute="top" secondItem="lQ6-x9-Tcu" secondAttribute="top" constant="-4" id="Rgg-Bx-6R4"/>
<constraint firstItem="Mwx-oc-6Cf" firstAttribute="bottom" secondItem="lQ6-x9-Tcu" secondAttribute="bottom" constant="8" id="Thj-eJ-Fvw"/>
<constraint firstItem="bh5-KV-5HI" firstAttribute="top" secondItem="Mwx-oc-6Cf" secondAttribute="top" id="WIh-RA-e0M"/>
<constraint firstItem="lQ6-x9-Tcu" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Mwx-oc-6Cf" secondAttribute="leading" id="bHE-Eq-ddT"/>
<constraint firstItem="lQ6-x9-Tcu" firstAttribute="centerX" secondItem="2qJ-Gw-Tlk" secondAttribute="centerX" id="dhZ-1K-ezQ"/>
<constraint firstItem="t42-v5-7DN" firstAttribute="leading" secondItem="2qJ-Gw-Tlk" secondAttribute="leading" id="eFN-9k-B2Z"/>
<constraint firstItem="Mwx-oc-6Cf" firstAttribute="trailing" secondItem="3Wa-fp-kMe" secondAttribute="trailing" constant="8" id="jLl-sH-LgH"/>
<constraint firstAttribute="bottom" secondItem="t42-v5-7DN" secondAttribute="bottom" id="ovl-eX-nHk"/>
<constraint firstItem="t42-v5-7DN" firstAttribute="top" secondItem="2qJ-Gw-Tlk" secondAttribute="top" id="xAA-lL-BRQ"/>
<constraint firstAttribute="trailing" secondItem="t42-v5-7DN" secondAttribute="trailing" id="xh0-GX-R9G"/>
<constraint firstItem="bh5-KV-5HI" firstAttribute="leading" secondItem="Mwx-oc-6Cf" secondAttribute="leading" constant="8" id="yP2-xR-h9i"/>
</constraints>
<point key="canvasLocation" x="-460" y="-627"/>
</view>
</objects>
<resources>
<image name="multiply.circle.fill" catalog="system" width="128" height="123"/>
<image name="square.and.arrow.up.fill" catalog="system" width="117" height="128"/>
<namedColor name="fullScreenBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</namedColor>
<namedColor name="primaryAccentColor">
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -0,0 +1,49 @@
//
// OpenInBrowserActivity.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 1/9/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
final class OpenInBrowserActivity: UIActivity {
private var activityItems: [Any]?
override var activityTitle: String? {
return NSLocalizedString("Open in Browser", comment: "Open in Browser")
}
override var activityImage: UIImage? {
return UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
}
override var activityType: UIActivity.ActivityType? {
return UIActivity.ActivityType(rawValue: "com.rancharo.NetNewsWire-Evergreen.safari")
}
override static var activityCategory: UIActivity.Category {
return .action
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
return true
}
override func prepare(withActivityItems activityItems: [Any]) {
self.activityItems = activityItems
}
override func perform() {
guard let url = activityItems?.first(where: { $0 is URL }) as? URL else {
activityDidFinish(false)
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
activityDidFinish(true)
}
}

View File

@@ -0,0 +1,862 @@
//
// WebViewController.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 12/28/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
@preconcurrency import WebKit
import RSCore
import Account
import Articles
import SafariServices
import MessageUI
protocol WebViewControllerDelegate: AnyObject {
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
}
final class WebViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
static let showFeedInspector = "showFeedInspector"
}
private var topShowBarsView: UIView!
private var bottomShowBarsView: UIView!
private var topShowBarsViewConstraint: NSLayoutConstraint!
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
var webView: WKWebView? {
return view.subviews[0] as? WKWebView
}
private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
private var isFullScreenAvailable: Bool {
return AppDefaults.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed
}
private lazy var articleIconSchemeHandler = ArticleIconSchemeHandler(delegate: self)
private lazy var transition = ImageTransition(controller: self)
private var clickedImageCompletion: (() -> Void)?
private var articleExtractor: ArticleExtractor?
var extractedArticle: ExtractedArticle? {
didSet {
windowScrollY = 0
}
}
var isShowingExtractedArticle = false
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
didSet {
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
}
}
weak var coordinator: SceneCoordinator!
weak var delegate: WebViewControllerDelegate?
private(set) var article: Article?
let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 0.3)
var windowScrollY = 0
private var restoreWindowScrollY: Int?
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(currentArticleThemeDidChangeNotification(_:)), name: .CurrentArticleThemeDidChangeNotification, object: nil)
// Configure the tap zones
configureTopShowBarsView()
configureBottomShowBarsView()
loadWebView()
}
// MARK: Notifications
@objc func feedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func currentArticleThemeDidChangeNotification(_ note: Notification) {
loadWebView()
}
// MARK: Actions
@objc func showBars(_ sender: Any) {
showBars()
}
// MARK: API
func setArticle(_ article: Article?, updateView: Bool = true) {
stopArticleExtractor()
if article != self.article {
self.article = article
if updateView {
if article?.feed?.isArticleExtractorAlwaysOn ?? false {
startArticleExtractor()
}
windowScrollY = 0
loadWebView()
}
}
}
func setScrollPosition(isShowingExtractedArticle: Bool, articleWindowScrollY: Int) {
if isShowingExtractedArticle {
switch articleExtractor?.state {
case .ready:
restoreWindowScrollY = articleWindowScrollY
startArticleExtractor()
case .complete:
windowScrollY = articleWindowScrollY
loadWebView()
case .processing:
restoreWindowScrollY = articleWindowScrollY
default:
restoreWindowScrollY = articleWindowScrollY
startArticleExtractor()
}
} else {
windowScrollY = articleWindowScrollY
loadWebView()
}
}
func focus() {
webView?.becomeFirstResponder()
}
func canScrollDown() -> Bool {
guard let webView = webView else { return false }
return webView.scrollView.contentOffset.y < finalScrollPosition(scrollingUp: false)
}
func canScrollUp() -> Bool {
guard let webView = webView else { return false }
return webView.scrollView.contentOffset.y > finalScrollPosition(scrollingUp: true)
}
private func scrollPage(up scrollingUp: Bool) {
guard let webView = webView else { return }
let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale
let scrollToY: CGFloat = {
let scrollDistance = webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap
let fullScroll = webView.scrollView.contentOffset.y + (scrollingUp ? -scrollDistance : scrollDistance)
let final = finalScrollPosition(scrollingUp: scrollingUp)
return (scrollingUp ? fullScroll > final : fullScroll < final) ? fullScroll : final
}()
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
}
func scrollPageDown() {
scrollPage(up: false)
}
func scrollPageUp() {
scrollPage(up: true)
}
func hideClickedImage() {
webView?.evaluateJavaScript("hideClickedImage();")
}
func showClickedImage(completion: @escaping () -> Void) {
clickedImageCompletion = completion
webView?.evaluateJavaScript("showClickedImage();")
}
func fullReload() {
loadWebView(replaceExistingWebView: true)
}
func showBars() {
AppDefaults.articleFullscreenEnabled = false
coordinator.showStatusBar()
topShowBarsViewConstraint?.constant = 0
bottomShowBarsViewConstraint?.constant = 0
navigationController?.setNavigationBarHidden(false, animated: true)
navigationController?.setToolbarHidden(false, animated: true)
configureContextMenuInteraction()
}
func hideBars() {
if isFullScreenAvailable {
AppDefaults.articleFullscreenEnabled = true
coordinator.hideStatusBar()
topShowBarsViewConstraint?.constant = -44.0
bottomShowBarsViewConstraint?.constant = 44.0
navigationController?.setNavigationBarHidden(true, animated: true)
navigationController?.setToolbarHidden(true, animated: true)
configureContextMenuInteraction()
}
}
func toggleArticleExtractor() {
guard let article = article else {
return
}
guard articleExtractor?.state != .processing else {
stopArticleExtractor()
loadWebView()
return
}
guard !isShowingExtractedArticle else {
isShowingExtractedArticle = false
loadWebView()
articleExtractorButtonState = .off
return
}
if let articleExtractor = articleExtractor {
if article.preferredLink == articleExtractor.articleLink {
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
} else {
startArticleExtractor()
}
}
func stopArticleExtractorIfProcessing() {
if articleExtractor?.state == .processing {
stopArticleExtractor()
}
}
func stopWebViewActivity() {
if let webView = webView {
stopMediaPlayback(webView)
cancelImageLoad(webView)
}
}
func showActivityDialog(popOverBarButtonItem: UIBarButtonItem? = nil) {
guard let url = article?.preferredURL else { return }
let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [FindInArticleActivity(), OpenInBrowserActivity()])
activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem
present(activityViewController, animated: true)
}
func openInAppBrowser() {
guard let url = article?.preferredURL else { return }
if AppDefaults.useSystemBrowser {
UIApplication.shared.open(url, options: [:])
} else {
openURLInSafariViewController(url)
}
}
}
// MARK: - ArticleIconSchemeHandlerDelegate
extension WebViewController: ArticleIconSchemeHandlerDelegate {
func articleIconSchemeHandler(_: ArticleIconSchemeHandler, imageForArticleID articleID: String) -> IconImage? {
guard let article else {
assertionFailure("Did not expect request for article image when there is no current article.")
return nil
}
guard articleID == article.articleID else {
assertionFailure("Expected articleID to match current articleID.")
return nil
}
return article.iconImage() // May be nil  not a programming error
}
}
// MARK: ArticleExtractorDelegate
extension WebViewController: ArticleExtractorDelegate {
func articleExtractionDidFail(with: Error) {
stopArticleExtractor()
articleExtractorButtonState = .error
loadWebView()
}
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
if articleExtractor?.state != .cancelled {
self.extractedArticle = extractedArticle
if let restoreWindowScrollY = restoreWindowScrollY {
windowScrollY = restoreWindowScrollY
}
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
}
}
// MARK: UIContextMenuInteractionDelegate
extension WebViewController: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] _ in
guard let self = self else { return nil }
var menus = [UIMenu]()
var navActions = [UIAction]()
if let action = self.prevArticleAction() {
navActions.append(action)
}
if let action = self.nextArticleAction() {
navActions.append(action)
}
if !navActions.isEmpty {
menus.append(UIMenu(title: "", options: .displayInline, children: navActions))
}
var toggleActions = [UIAction]()
if let action = self.toggleReadAction() {
toggleActions.append(action)
}
toggleActions.append(self.toggleStarredAction())
menus.append(UIMenu(title: "", options: .displayInline, children: toggleActions))
if let action = self.nextUnreadArticleAction() {
menus.append(UIMenu(title: "", options: .displayInline, children: [action]))
}
menus.append(UIMenu(title: "", options: .displayInline, children: [self.toggleArticleExtractorAction()]))
menus.append(UIMenu(title: "", options: .displayInline, children: [self.shareAction()]))
return UIMenu(title: "", children: menus)
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
coordinator.showBrowserForCurrentArticle()
}
}
// MARK: WKNavigationDelegate
extension WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if components?.scheme == "http" || components?.scheme == "https" {
decisionHandler(.cancel)
if AppDefaults.useSystemBrowser {
UIApplication.shared.open(url, options: [:])
} else {
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
guard didOpen == false else {
return
}
self.openURLInSafariViewController(url)
}
}
} else if components?.scheme == "mailto" {
decisionHandler(.cancel)
guard let emailAddress = url.percentEncodedEmailAddress else {
return
}
if UIApplication.shared.canOpenURL(emailAddress) {
UIApplication.shared.open(emailAddress, options: [.universalLinksOnly: false], completionHandler: nil)
} else {
let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: NSLocalizedString("This device cannot send emails.", comment: "This device cannot send emails."), preferredStyle: .alert)
alert.addAction(.init(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil))
self.present(alert, animated: true, completion: nil)
}
} else if components?.scheme == "tel" {
decisionHandler(.cancel)
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [.universalLinksOnly: false], completionHandler: nil)
}
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
fullReload()
}
}
// MARK: WKUIDelegate
extension WebViewController: WKUIDelegate {
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
// link preview launch Safari when the link preview is tapped. In theory, you should be able to get
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_()_/¯
}
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
guard let url = navigationAction.request.url else {
return nil
}
openURL(url)
return nil
}
}
// MARK: WKScriptMessageHandler
extension WebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
clickedImageCompletion?()
case MessageName.imageWasClicked:
imageWasClicked(body: message.body as? String)
case MessageName.showFeedInspector:
if let feed = article?.feed {
coordinator.showFeedInspector(for: feed)
}
default:
return
}
}
}
// MARK: UIViewControllerTransitioningDelegate
extension WebViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
}
// MARK:
extension WebViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
}
@objc func scrollPositionDidChange() {
webView?.evaluateJavaScript("window.scrollY") { (scrollY, error) in
guard error == nil else { return }
let javascriptScrollY = scrollY as? Int ?? 0
// I don't know why this value gets returned sometimes, but it is in error
guard javascriptScrollY != 33554432 else { return }
self.windowScrollY = javascriptScrollY
}
}
}
// MARK: JSON
private struct ImageClickMessage: Codable {
let x: Float
let y: Float
let width: Float
let height: Float
let imageTitle: String?
let imageURL: String
}
// MARK: Private
private extension WebViewController {
func loadWebView(replaceExistingWebView: Bool = false) {
guard isViewLoaded else { return }
if !replaceExistingWebView, let webView {
self.renderPage(webView)
return
}
let configuration = WebViewConfiguration.configuration(with: articleIconSchemeHandler)
let webView = WKWebView(frame: self.view.bounds, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = .clear
// Add the webview - using autolayout will cause fullscreen video to fail and lose the web view
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.view.insertSubview(webView, at: 0)
// UISplitViewController reports the wrong size to WKWebView which can cause horizontal
// rubberbanding on the iPad. This interferes with our UIPageViewController preventing
// us from easily swiping between WKWebViews. This hack fixes that.
webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0)
webView.scrollView.setZoomScale(1.0, animated: false)
self.view.setNeedsLayout()
// Configure the webview
webView.navigationDelegate = self
webView.uiDelegate = self
webView.scrollView.delegate = self
self.configureContextMenuInteraction()
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
self.renderPage(webView)
}
func renderPage(_ webView: WKWebView?) {
guard let webView = webView else { return }
let theme = ArticleThemesManager.shared.currentTheme
let rendering: ArticleRenderer.Rendering
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
rendering = ArticleRenderer.loadingHTML(theme: theme)
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
} else if let article = article, let extractedArticle = extractedArticle {
if isShowingExtractedArticle {
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
} else {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
}
} else if let article = article {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
} else {
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
}
let substitutions = [
"title": rendering.title,
"baseURL": rendering.baseURL,
"style": rendering.style,
"body": rendering.html,
"windowScrollY": String(windowScrollY)
]
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
webView.loadHTMLString(html, baseURL: URL(string: rendering.baseURL))
}
func finalScrollPosition(scrollingUp: Bool) -> CGFloat {
guard let webView = webView else { return 0 }
if scrollingUp {
return -webView.scrollView.safeAreaInsets.top
} else {
return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom
}
}
func startArticleExtractor() {
guard articleExtractor == nil else { return }
if let link = article?.preferredLink, let extractor = ArticleExtractor(link) {
extractor.delegate = self
extractor.process()
articleExtractor = extractor
articleExtractorButtonState = .animated
}
}
func stopArticleExtractor() {
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
articleExtractorButtonState = .off
}
func reloadArticleImage() {
guard let article = article else { return }
var components = URLComponents()
components.scheme = ArticleRenderer.imageIconScheme
components.path = article.articleID
if let imageSrc = components.string {
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
}
}
func imageWasClicked(body: String?) {
guard let webView = webView,
let body = body,
let data = body.data(using: .utf8),
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
let range = clickMessage.imageURL.range(of: ";base64,")
else { return }
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
transition.originFrame = webView.convert(rect, to: nil)
if navigationController?.navigationBar.isHidden ?? false {
transition.maskFrame = webView.convert(webView.frame, to: nil)
} else {
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
}
transition.originImage = image
coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
}
}
func stopMediaPlayback(_ webView: WKWebView) {
webView.evaluateJavaScript("stopMediaPlayback();")
}
func cancelImageLoad(_ webView: WKWebView) {
webView.evaluateJavaScript("cancelImageLoad();")
}
func configureTopShowBarsView() {
topShowBarsView = UIView()
topShowBarsView.backgroundColor = .clear
topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topShowBarsView)
if AppDefaults.logicalArticleFullscreenEnabled {
topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0)
} else {
topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0)
}
NSLayoutConstraint.activate([
topShowBarsViewConstraint,
view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor),
topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
])
topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
}
func configureBottomShowBarsView() {
bottomShowBarsView = UIView()
topShowBarsView.backgroundColor = .clear
bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bottomShowBarsView)
if AppDefaults.logicalArticleFullscreenEnabled {
bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0)
} else {
bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0)
}
NSLayoutConstraint.activate([
bottomShowBarsViewConstraint,
view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor),
bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
])
bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
}
func configureContextMenuInteraction() {
if isFullScreenAvailable {
if navigationController?.isNavigationBarHidden ?? false {
webView?.addInteraction(contextMenuInteraction)
} else {
webView?.removeInteraction(contextMenuInteraction)
}
}
}
func contextMenuPreviewProvider() -> UIViewController {
ContextMenuPreviewViewController(article: article)
}
func prevArticleAction() -> UIAction? {
guard coordinator.isPrevArticleAvailable else { return nil }
let title = NSLocalizedString("Previous Article", comment: "Previous Article")
return UIAction(title: title, image: AppImage.previousArticle) { [weak self] _ in
self?.coordinator.selectPrevArticle()
}
}
func nextArticleAction() -> UIAction? {
guard coordinator.isNextArticleAvailable else { return nil }
let title = NSLocalizedString("Next Article", comment: "Next Article")
return UIAction(title: title, image: AppImage.nextArticle) { [weak self] _ in
self?.coordinator.selectNextArticle()
}
}
func toggleReadAction() -> UIAction? {
guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil }
let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
let readImage = article.status.read ? AppImage.circleClosed : AppImage.circleOpen
return UIAction(title: title, image: readImage) { [weak self] _ in
self?.coordinator.toggleReadForCurrentArticle()
}
}
func toggleStarredAction() -> UIAction {
let starred = article?.status.starred ?? false
let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
let starredImage = starred ? AppImage.starOpen : AppImage.starClosed
return UIAction(title: title, image: starredImage) { [weak self] _ in
self?.coordinator.toggleStarredForCurrentArticle()
}
}
func nextUnreadArticleAction() -> UIAction? {
guard coordinator.isAnyUnreadAvailable else { return nil }
let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article")
return UIAction(title: title, image: AppImage.nextUnreadArticle) { [weak self] _ in
self?.coordinator.selectNextUnread()
}
}
func toggleArticleExtractorAction() -> UIAction {
let extracted = articleExtractorButtonState == .on
let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
let extractorImage = extracted ? AppImage.articleExtractorOffSF : AppImage.articleExtractorOnSF
return UIAction(title: title, image: extractorImage) { [weak self] _ in
self?.toggleArticleExtractor()
}
}
func shareAction() -> UIAction {
let title = NSLocalizedString("Share", comment: "Share")
return UIAction(title: title, image: AppImage.share) { [weak self] _ in
self?.showActivityDialog()
}
}
// If the resource cannot be opened with an installed app, present the web view.
func openURL(_ url: URL) {
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
assert(Thread.isMainThread)
guard didOpen == false else {
return
}
self.openURLInSafariViewController(url)
}
}
func openURLInSafariViewController(_ url: URL) {
let viewController = SFSafariViewController(url: url)
present(viewController, animated: true)
}
}
// MARK: Find in Article
private struct FindInArticleOptions: Codable {
var text: String
var caseSensitive = false
var regex = false
}
internal struct FindInArticleState: Codable {
struct WebViewClientRect: Codable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct FindInArticleResult: Codable {
let rects: [WebViewClientRect]
let bounds: WebViewClientRect
let index: UInt
let matchGroups: [String]
}
let index: UInt?
let results: [FindInArticleResult]
let count: UInt
}
extension WebViewController {
func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) {
guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else {
return
}
let encoded = json.base64EncodedString()
webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") { (result, error) in
guard error == nil,
let b64 = result as? String,
let rawData = Data(base64Encoded: b64),
let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else {
return
}
completionHandler(findState)
}
}
func endSearch() {
webView?.evaluateJavaScript("endFind()")
}
func selectNextSearchResult() {
webView?.evaluateJavaScript("selectNextResult()")
}
func selectPreviousSearchResult() {
webView?.evaluateJavaScript("selectPreviousResult()")
}
}

View File

@@ -0,0 +1,25 @@
//
// WrapperScriptMessageHandler.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/4/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import WebKit
final class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler {
// We need to wrap a message handler to prevent a circlular reference
private weak var handler: WKScriptMessageHandler?
init(_ handler: WKScriptMessageHandler) {
self.handler = handler
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
handler?.userContentController(userContentController, didReceive: message)
}
}

View File

@@ -0,0 +1,23 @@
//
// MainFeedRowIdentifier.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/20/21.
// Copyright © 2021 Ranchero Software. All rights reserved.
//
import Foundation
final class MainFeedRowIdentifier: NSObject, NSCopying {
var indexPath: IndexPath
init(indexPath: IndexPath) {
self.indexPath = indexPath
}
func copy(with zone: NSZone? = nil) -> Any {
return self
}
}

View File

@@ -0,0 +1,243 @@
//
// MainFeedTableViewCell.swift
// NetNewsWire
//
// Created by Brent Simmons on 8/1/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import RSCore
import Account
import RSTree
protocol MainFeedTableViewCellDelegate: AnyObject {
func mainFeedTableViewCellDisclosureDidToggle(_ sender: MainFeedTableViewCell, expanding: Bool)
}
final class MainFeedTableViewCell: VibrantTableViewCell {
weak var delegate: MainFeedTableViewCellDelegate?
override var accessibilityLabel: String? {
get {
if unreadCount > 0 {
let unreadLabel = NSLocalizedString("unread", comment: "Unread label for accessibility")
return "\(name) \(unreadCount) \(unreadLabel)"
} else {
return name
}
}
set {
}
}
var iconImage: IconImage? {
didSet {
iconView.iconImage = iconImage
}
}
var isDisclosureAvailable = false {
didSet {
if isDisclosureAvailable != oldValue {
setNeedsLayout()
}
}
}
var isSeparatorShown = true {
didSet {
if isSeparatorShown != oldValue {
if isSeparatorShown {
showView(bottomSeparatorView)
} else {
hideView(bottomSeparatorView)
}
}
}
}
var unreadCount: Int {
get {
return unreadCountView.unreadCount
}
set {
if unreadCountView.unreadCount != newValue {
unreadCountView.unreadCount = newValue
unreadCountView.isHidden = (newValue < 1)
setNeedsLayout()
}
}
}
var name: String {
get {
return titleView.text ?? ""
}
set {
if titleView.text != newValue {
titleView.text = newValue
setNeedsLayout()
}
}
}
private let titleView: UILabel = {
let label = NonIntrinsicLabel()
label.numberOfLines = 0
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
label.lineBreakMode = .byTruncatingTail
label.font = .preferredFont(forTextStyle: .body)
return label
}()
private let iconView = IconView()
private let bottomSeparatorView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.separator
view.alpha = 0.5
return view
}()
private var isDisclosureExpanded = false
private var disclosureButton: UIButton?
private var unreadCountView = MainFeedUnreadCountView(frame: CGRect.zero)
private var isShowingEditControl = false
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func setDisclosure(isExpanded: Bool, animated: Bool) {
isDisclosureExpanded = isExpanded
let duration = animated ? 0.3 : 0.0
UIView.animate(withDuration: duration) {
if self.isDisclosureExpanded {
self.disclosureButton?.accessibilityLabel = NSLocalizedString("Collapse Folder", comment: "Collapse Folder")
self.disclosureButton?.imageView?.transform = CGAffineTransform(rotationAngle: 1.570796)
} else {
self.disclosureButton?.accessibilityLabel = NSLocalizedString("Expand Folder", comment: "Expand Folder")
self.disclosureButton?.imageView?.transform = CGAffineTransform(rotationAngle: 0)
}
}
}
override func willTransition(to state: UITableViewCell.StateMask) {
super.willTransition(to: state)
isShowingEditControl = state.contains(.showingEditControl)
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let layout = MainFeedTableViewCellLayout(
cellWidth: bounds.size.width,
insets: safeAreaInsets,
label: titleView,
unreadCountView: unreadCountView,
showingEditingControl: isShowingEditControl,
indent: indentationLevel == 1,
shouldShowDisclosure: isDisclosureAvailable
)
return CGSize(width: bounds.width, height: layout.height)
}
override func layoutSubviews() {
super.layoutSubviews()
let layout = MainFeedTableViewCellLayout(
cellWidth: bounds.size.width,
insets: safeAreaInsets,
label: titleView,
unreadCountView: unreadCountView,
showingEditingControl: isShowingEditControl,
indent: indentationLevel == 1,
shouldShowDisclosure: isDisclosureAvailable
)
layoutWith(layout)
}
@objc func buttonPressed(_ sender: UIButton) {
if isDisclosureAvailable {
setDisclosure(isExpanded: !isDisclosureExpanded, animated: true)
delegate?.mainFeedTableViewCellDisclosureDidToggle(self, expanding: isDisclosureExpanded)
}
}
override func updateVibrancy(animated: Bool) {
super.updateVibrancy(animated: animated)
let iconTintColor: UIColor
if isHighlighted || isSelected {
iconTintColor = AppColor.vibrantText
} else {
if let preferredColor = iconImage?.preferredColor {
iconTintColor = UIColor(cgColor: preferredColor)
} else {
iconTintColor = AppColor.secondaryAccent
}
}
if animated {
UIView.animate(withDuration: Self.duration) {
self.iconView.tintColor = iconTintColor
}
} else {
self.iconView.tintColor = iconTintColor
}
updateLabelVibrancy(titleView, color: labelColor, animated: animated)
}
}
private extension MainFeedTableViewCell {
func commonInit() {
addSubviewAtInit(unreadCountView)
addSubviewAtInit(iconView)
addSubviewAtInit(titleView)
addDisclosureView()
addSubviewAtInit(bottomSeparatorView)
}
func addDisclosureView() {
disclosureButton = NonIntrinsicButton(type: .roundedRect)
disclosureButton!.addTarget(self, action: #selector(buttonPressed(_:)), for: UIControl.Event.touchUpInside)
disclosureButton?.setImage(AppImage.disclosure, for: .normal)
disclosureButton?.tintColor = AppColor.controlBackground
disclosureButton?.imageView?.contentMode = .center
disclosureButton?.imageView?.clipsToBounds = false
disclosureButton?.addInteraction(UIPointerInteraction())
addSubviewAtInit(disclosureButton!)
}
func addSubviewAtInit(_ view: UIView) {
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
}
func layoutWith(_ layout: MainFeedTableViewCellLayout) {
iconView.setFrameIfNotEqual(layout.faviconRect)
titleView.setFrameIfNotEqual(layout.titleRect)
unreadCountView.setFrameIfNotEqual(layout.unreadCountRect)
disclosureButton?.setFrameIfNotEqual(layout.disclosureButtonRect)
disclosureButton?.isHidden = !isDisclosureAvailable
bottomSeparatorView.setFrameIfNotEqual(layout.separatorRect)
}
func hideView(_ view: UIView) {
if !view.isHidden {
view.isHidden = true
}
}
func showView(_ view: UIView) {
if view.isHidden {
view.isHidden = false
}
}
}

View File

@@ -0,0 +1,148 @@
//
// MainFeedTableViewCellLayout.swift
// NetNewsWire
//
// Created by Brent Simmons on 11/24/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
struct MainFeedTableViewCellLayout {
private static let indentWidth = CGFloat(integerLiteral: 42)
private static let editingControlIndent = CGFloat(integerLiteral: 40)
private static let imageSize = CGSize(width: 24, height: 24)
private static let imageMarginRight = CGFloat(integerLiteral: 11)
private static let labelMarginRight = CGFloat(integerLiteral: 8)
private static let unreadCountMarginRight = CGFloat(integerLiteral: 16)
private static let disclosureButtonSize = CGSize(width: 44, height: 44)
private static let verticalPadding = CGFloat(integerLiteral: 11)
private static let minRowHeight = CGFloat(integerLiteral: 44)
static let faviconCornerRadius = CGFloat(integerLiteral: 2)
let faviconRect: CGRect
let titleRect: CGRect
let unreadCountRect: CGRect
let disclosureButtonRect: CGRect
let separatorRect: CGRect
let height: CGFloat
init(cellWidth: CGFloat, insets: UIEdgeInsets, label: UILabel, unreadCountView: MainFeedUnreadCountView, showingEditingControl: Bool, indent: Bool, shouldShowDisclosure: Bool) {
var initialIndent = insets.left
if indent {
initialIndent += MainFeedTableViewCellLayout.indentWidth
}
let bounds = CGRect(x: initialIndent, y: 0.0, width: floor(cellWidth - initialIndent - insets.right), height: 0.0)
// Disclosure Button
var rDisclosure = CGRect.zero
if shouldShowDisclosure {
rDisclosure.size = MainFeedTableViewCellLayout.disclosureButtonSize
rDisclosure.origin.x = bounds.origin.x
}
// Favicon
var rFavicon = CGRect.zero
if !shouldShowDisclosure {
let x = bounds.origin.x
let y = UIFontMetrics.default.scaledValue(for: MainFeedTableViewCellLayout.verticalPadding) +
label.font.lineHeight / 2.0 -
MainFeedTableViewCellLayout.imageSize.height / 2.0
rFavicon = CGRect(x: x, y: y, width: MainFeedTableViewCellLayout.imageSize.width, height: MainFeedTableViewCellLayout.imageSize.height)
}
// Unread Count
let unreadCountSize = unreadCountView.contentSize
let unreadCountIsHidden = unreadCountView.unreadCount < 1
var rUnread = CGRect.zero
if !unreadCountIsHidden {
rUnread.size = unreadCountSize
rUnread.origin.x = bounds.maxX - (MainFeedTableViewCellLayout.unreadCountMarginRight + unreadCountSize.width)
}
// Title
var rLabelx = insets.left + MainFeedTableViewCellLayout.disclosureButtonSize.width
if !shouldShowDisclosure {
rLabelx = rLabelx + MainFeedTableViewCellLayout.imageSize.width + MainFeedTableViewCellLayout.imageMarginRight
}
let rLabely = UIFontMetrics.default.scaledValue(for: MainFeedTableViewCellLayout.verticalPadding)
var labelWidth = CGFloat.zero
if !unreadCountIsHidden {
labelWidth = cellWidth - (rLabelx + MainFeedTableViewCellLayout.labelMarginRight + (cellWidth - rUnread.minX))
} else {
labelWidth = cellWidth - (rLabelx + MainFeedTableViewCellLayout.labelMarginRight)
}
let labelSizeInfo = MultilineUILabelSizer.size(for: label.text ?? "", font: label.font, numberOfLines: 0, width: Int(floor(labelWidth)))
// Now that we've got everything (especially the label) computed without the editing controls, update for them.
// We do this because we don't want the row height to change when the editing controls are brought out. We will
// handle the missing space, but removing it from the label and truncating.
if showingEditingControl {
rDisclosure.origin.x += MainFeedTableViewCellLayout.editingControlIndent
rFavicon.origin.x += MainFeedTableViewCellLayout.editingControlIndent
rLabelx += MainFeedTableViewCellLayout.editingControlIndent
if !unreadCountIsHidden {
rUnread.origin.x -= MainFeedTableViewCellLayout.editingControlIndent
labelWidth = cellWidth - (rLabelx + MainFeedTableViewCellLayout.labelMarginRight + (cellWidth - rUnread.minX))
} else {
labelWidth = cellWidth - (rLabelx + MainFeedTableViewCellLayout.labelMarginRight + MainFeedTableViewCellLayout.editingControlIndent)
}
}
var rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height)
// Determine cell height
let paddedLabelHeight = rLabel.maxY + UIFontMetrics.default.scaledValue(for: MainFeedTableViewCellLayout.verticalPadding)
let maxGraphicsHeight = [rFavicon, rUnread, rDisclosure].maxY()
var cellHeight = max(paddedLabelHeight, maxGraphicsHeight)
if cellHeight < MainFeedTableViewCellLayout.minRowHeight {
cellHeight = MainFeedTableViewCellLayout.minRowHeight
}
// Center in Cell
let newBounds = CGRect(x: bounds.origin.x, y: bounds.origin.y, width: bounds.width, height: cellHeight)
if !unreadCountIsHidden {
rUnread = MainFeedTableViewCellLayout.centerVertically(rUnread, newBounds)
}
if shouldShowDisclosure {
rDisclosure = MainFeedTableViewCellLayout.centerVertically(rDisclosure, newBounds)
}
// Small fonts and the Favicon need centered if we hit the minimum row height
if cellHeight == MainFeedTableViewCellLayout.minRowHeight {
rLabel = MainFeedTableViewCellLayout.centerVertically(rLabel, newBounds)
rFavicon = MainFeedTableViewCellLayout.centerVertically(rFavicon, newBounds)
}
// Separator Insets
let separatorInset = MainFeedTableViewCellLayout.disclosureButtonSize.width
separatorRect = CGRect(x: separatorInset, y: cellHeight - 0.5, width: cellWidth - separatorInset, height: 0.5)
// Assign the properties
self.height = cellHeight
self.faviconRect = rFavicon
self.unreadCountRect = rUnread
self.disclosureButtonRect = rDisclosure
self.titleRect = rLabel
}
// Ideally this will be implemented in RSCore (see RSGeometry)
static func centerVertically(_ originalRect: CGRect, _ containerRect: CGRect) -> CGRect {
var result = originalRect
result.origin.y = containerRect.midY - (result.height / 2.0)
result = result.integral
result.size = originalRect.size
return result
}
}

View File

@@ -0,0 +1,209 @@
//
// MainFeedTableViewSectionHeader.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/18/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
protocol MainFeedTableViewSectionHeaderDelegate: AnyObject {
func mainFeedTableViewSectionHeaderDisclosureDidToggle(_ sender: MainFeedTableViewSectionHeader)
}
final class MainFeedTableViewSectionHeader: UITableViewHeaderFooterView {
weak var delegate: MainFeedTableViewSectionHeaderDelegate?
override var accessibilityLabel: String? {
get {
if unreadCount > 0 {
let unreadLabel = NSLocalizedString("unread", comment: "Unread label for accessibility")
return "\(name) \(unreadCount) \(unreadLabel) \(expandedStateMessage) "
} else {
return "\(name) \(expandedStateMessage) "
}
}
set {
}
}
private var expandedStateMessage: String {
if disclosureExpanded {
return NSLocalizedString("Expanded", comment: "Disclosure button expanded state for accessibility")
}
return NSLocalizedString("Collapsed", comment: "Disclosure button collapsed state for accessibility")
}
var unreadCount: Int {
get {
return unreadCountView.unreadCount
}
set {
if unreadCountView.unreadCount != newValue {
unreadCountView.unreadCount = newValue
updateUnreadCountView()
setNeedsLayout()
}
}
}
var name: String {
get {
return titleView.text ?? ""
}
set {
if titleView.text != newValue {
titleView.text = newValue
setNeedsLayout()
}
}
}
var disclosureExpanded = false {
didSet {
updateExpandedState(animate: true)
updateUnreadCountView()
}
}
var isLastSection = false
private let titleView: UILabel = {
let label = NonIntrinsicLabel()
label.numberOfLines = 0
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
label.font = .preferredFont(forTextStyle: .body)
return label
}()
private let unreadCountView = MainFeedUnreadCountView(frame: CGRect.zero)
private lazy var disclosureButton: UIButton = {
let button = NonIntrinsicButton()
button.tintColor = UIColor.tertiaryLabel
button.setImage(AppImage.disclosure, for: .normal)
button.contentMode = .center
button.addInteraction(UIPointerInteraction())
button.addTarget(self, action: #selector(toggleDisclosure), for: .touchUpInside)
return button
}()
private let topSeparatorView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.separator
return view
}()
private let bottomSeparatorView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.separator
return view
}()
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let layout = MainFeedTableViewSectionHeaderLayout(cellWidth: size.width, insets: safeAreaInsets, label: titleView, unreadCountView: unreadCountView)
return CGSize(width: bounds.width, height: layout.height)
}
override func layoutSubviews() {
super.layoutSubviews()
let layout = MainFeedTableViewSectionHeaderLayout(cellWidth: contentView.bounds.size.width,
insets: contentView.safeAreaInsets,
label: titleView,
unreadCountView: unreadCountView)
layoutWith(layout)
}
}
private extension MainFeedTableViewSectionHeader {
@objc func toggleDisclosure() {
delegate?.mainFeedTableViewSectionHeaderDisclosureDidToggle(self)
}
func commonInit() {
addSubviewAtInit(unreadCountView)
addSubviewAtInit(titleView)
addSubviewAtInit(disclosureButton)
updateExpandedState(animate: false)
addBackgroundView()
addSubviewAtInit(topSeparatorView)
addSubviewAtInit(bottomSeparatorView)
}
func updateExpandedState(animate: Bool) {
if !isLastSection && self.disclosureExpanded {
self.bottomSeparatorView.isHidden = false
}
let duration = animate ? 0.3 : 0.0
UIView.animate(
withDuration: duration,
animations: {
if self.disclosureExpanded {
self.disclosureButton.transform = CGAffineTransform(rotationAngle: 1.570796)
} else {
self.disclosureButton.transform = CGAffineTransform(rotationAngle: 0)
}
}, completion: { _ in
if !self.isLastSection && !self.disclosureExpanded {
self.bottomSeparatorView.isHidden = true
}
})
}
func updateUnreadCountView() {
if !disclosureExpanded && unreadCount > 0 {
UIView.animate(withDuration: 0.3) {
self.unreadCountView.alpha = 1
}
} else {
UIView.animate(withDuration: 0.3) {
self.unreadCountView.alpha = 0
}
}
}
func addSubviewAtInit(_ view: UIView) {
contentView.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
}
func layoutWith(_ layout: MainFeedTableViewSectionHeaderLayout) {
titleView.setFrameIfNotEqual(layout.titleRect)
unreadCountView.setFrameIfNotEqual(layout.unreadCountRect)
disclosureButton.setFrameIfNotEqual(layout.disclosureButtonRect)
let x = -safeAreaInsets.left
let width = safeAreaInsets.left + safeAreaInsets.right + frame.width
let height = 0.33
let top = CGRect(x: x, y: 0, width: width, height: height)
topSeparatorView.setFrameIfNotEqual(top)
let bottom = CGRect(x: x, y: frame.height - height, width: width, height: height)
bottomSeparatorView.setFrameIfNotEqual(bottom)
}
func addBackgroundView() {
self.backgroundView = UIView(frame: self.bounds)
self.backgroundView?.backgroundColor = AppColor.sectionHeader
}
}

View File

@@ -0,0 +1,90 @@
//
// MainFeedTableViewSectionHeaderLayout.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/5/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
struct MainFeedTableViewSectionHeaderLayout {
private static let labelMarginRight = CGFloat(integerLiteral: 8)
private static let unreadCountMarginRight = CGFloat(integerLiteral: 16)
private static let disclosureButtonSize = CGSize(width: 44, height: 44)
private static let verticalPadding = CGFloat(integerLiteral: 11)
private static let minRowHeight = CGFloat(integerLiteral: 44)
let titleRect: CGRect
let unreadCountRect: CGRect
let disclosureButtonRect: CGRect
let height: CGFloat
init(cellWidth: CGFloat, insets: UIEdgeInsets, label: UILabel, unreadCountView: MainFeedUnreadCountView) {
let bounds = CGRect(x: insets.left, y: 0.0, width: floor(cellWidth - insets.right), height: 0.0)
// Disclosure Button
var rDisclosure = CGRect.zero
rDisclosure.size = MainFeedTableViewSectionHeaderLayout.disclosureButtonSize
rDisclosure.origin.x = bounds.origin.x
// Unread Count
let unreadCountSize = unreadCountView.contentSize
let unreadCountIsHidden = unreadCountView.unreadCount < 1
var rUnread = CGRect.zero
if !unreadCountIsHidden {
rUnread.size = unreadCountSize
rUnread.origin.x = bounds.maxX - (MainFeedTableViewSectionHeaderLayout.unreadCountMarginRight + unreadCountSize.width)
}
// Max Unread Count
// We can't reload Section Headers so we don't let the title extend into the (probably) worse case Unread Count area.
let maxUnreadCountView = MainFeedUnreadCountView(frame: CGRect.zero)
maxUnreadCountView.unreadCount = 888
let maxUnreadCountSize = maxUnreadCountView.contentSize
// Title
let rLabelx = insets.left + MainFeedTableViewSectionHeaderLayout.disclosureButtonSize.width
let rLabely = UIFontMetrics.default.scaledValue(for: MainFeedTableViewSectionHeaderLayout.verticalPadding)
var labelWidth = CGFloat.zero
labelWidth = cellWidth - (rLabelx + MainFeedTableViewSectionHeaderLayout.labelMarginRight + maxUnreadCountSize.width + MainFeedTableViewSectionHeaderLayout.unreadCountMarginRight)
let labelSizeInfo = MultilineUILabelSizer.size(for: label.text ?? "", font: label.font, numberOfLines: 0, width: Int(floor(labelWidth)))
var rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height)
// Determine cell height
let paddedLabelHeight = rLabel.maxY + UIFontMetrics.default.scaledValue(for: MainFeedTableViewSectionHeaderLayout.verticalPadding)
let maxGraphicsHeight = [rUnread, rDisclosure].maxY()
var cellHeight = max(paddedLabelHeight, maxGraphicsHeight)
if cellHeight < MainFeedTableViewSectionHeaderLayout.minRowHeight {
cellHeight = MainFeedTableViewSectionHeaderLayout.minRowHeight
}
// Center in Cell
let newBounds = CGRect(x: bounds.origin.x, y: bounds.origin.y, width: bounds.width, height: cellHeight)
if !unreadCountIsHidden {
rUnread = MainFeedTableViewCellLayout.centerVertically(rUnread, newBounds)
}
rDisclosure = MainFeedTableViewCellLayout.centerVertically(rDisclosure, newBounds)
// Small fonts need centered if we hit the minimum row height
if cellHeight == MainFeedTableViewSectionHeaderLayout.minRowHeight {
rLabel = MainFeedTableViewCellLayout.centerVertically(rLabel, newBounds)
}
// Assign the properties
self.height = cellHeight
self.unreadCountRect = rUnread
self.disclosureButtonRect = rDisclosure
self.titleRect = rLabel
}
}

View File

@@ -0,0 +1,122 @@
//
// MainFeedUnreadCountView.swift
// NetNewsWire
//
// Created by Brent Simmons on 11/22/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import UIKit
class MainFeedUnreadCountView: UIView {
var padding: UIEdgeInsets {
return UIEdgeInsets(top: 1.0, left: 9.0, bottom: 1.0, right: 9.0)
}
let cornerRadius = 8.0
let bgColor = AppColor.controlBackground
var textColor: UIColor {
return UIColor.white
}
var textAttributes: [NSAttributedString.Key: AnyObject] {
let textFont = UIFont.preferredFont(forTextStyle: .caption1).bold()
return [NSAttributedString.Key.foregroundColor: textColor, NSAttributedString.Key.font: textFont, NSAttributedString.Key.kern: NSNull()]
}
var textSizeCache = [Int: CGSize]()
var unreadCount = 0 {
didSet {
contentSizeIsValid = false
invalidateIntrinsicContentSize()
setNeedsDisplay()
}
}
var unreadCountString: String {
return unreadCount < 1 ? "" : "\(unreadCount)"
}
private var contentSizeIsValid = false
private var _contentSize = CGSize.zero
override init(frame: CGRect) {
super.init(frame: frame)
self.isOpaque = false
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.isOpaque = false
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
textSizeCache = [Int: CGSize]()
contentSizeIsValid = false
setNeedsDisplay()
}
var contentSize: CGSize {
if !contentSizeIsValid {
var size = CGSize.zero
if unreadCount > 0 {
size = textSize()
size.width += (padding.left + padding.right)
size.height += (padding.top + padding.bottom)
}
_contentSize = size
contentSizeIsValid = true
}
return _contentSize
}
// Prevent autolayout from messing around with our frame settings
override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
func textSize() -> CGSize {
if unreadCount < 1 {
return CGSize.zero
}
if let cachedSize = textSizeCache[unreadCount] {
return cachedSize
}
var size = unreadCountString.size(withAttributes: textAttributes)
size.height = ceil(size.height)
size.width = ceil(size.width)
textSizeCache[unreadCount] = size
return size
}
func textRect() -> CGRect {
let size = textSize()
var r = CGRect.zero
r.size = size
r.origin.x = (bounds.maxX - padding.right) - r.size.width
r.origin.y = padding.top
return r
}
override func draw(_ dirtyRect: CGRect) {
let cornerRadii = CGSize(width: cornerRadius, height: cornerRadius)
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: .allCorners, cornerRadii: cornerRadii)
bgColor.setFill()
path.fill()
if unreadCount > 0 {
unreadCountString.draw(at: textRect().origin, withAttributes: textAttributes)
}
}
}

View File

@@ -0,0 +1,36 @@
//
// MainFeedViewController+Drag.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/20/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import MobileCoreServices
import Account
import UniformTypeIdentifiers
extension MainFeedViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let node = coordinator.nodeFor(indexPath), let feed = node.representedObject as? Feed else {
return [UIDragItem]()
}
let data = feed.url.data(using: .utf8)
let itemProvider = NSItemProvider()
itemProvider.registerDataRepresentation(forTypeIdentifier: UTType.url.identifier, visibility: .ownProcess) { completion in
Task { @MainActor in
completion(data, nil)
}
return nil
}
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = node
return [dragItem]
}
}

View File

@@ -0,0 +1,165 @@
//
// MainFeedViewController+Drop.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 11/20/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
import Account
import RSTree
extension MainFeedViewController: UITableViewDropDelegate {
func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
return session.localDragSession != nil
}
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
guard let destIndexPath = destinationIndexPath, destIndexPath.section > 0, tableView.hasActiveDrag else {
return UITableViewDropProposal(operation: .forbidden)
}
guard let destFeed = coordinator.nodeFor(destIndexPath)?.representedObject as? SidebarItem,
let destAccount = destFeed.account,
let destCell = tableView.cellForRow(at: destIndexPath) else {
return UITableViewDropProposal(operation: .forbidden)
}
// Validate account specific behaviors...
if destAccount.behaviors.contains(.disallowFeedInMultipleFolders),
let sourceNode = session.localDragSession?.items.first?.localObject as? Node,
let sourceFeed = sourceNode.representedObject as? Feed,
sourceFeed.account?.accountID != destAccount.accountID && destAccount.hasFeed(withURL: sourceFeed.url) {
return UITableViewDropProposal(operation: .forbidden)
}
// Determine the correct drop proposal
if destFeed is Folder {
if session.location(in: destCell).y >= 0 {
return UITableViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
} else {
return UITableViewDropProposal(operation: .move, intent: .unspecified)
}
} else {
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}
}
func tableView(_ tableView: UITableView, performDropWith dropCoordinator: UITableViewDropCoordinator) {
guard let dragItem = dropCoordinator.items.first?.dragItem,
let dragNode = dragItem.localObject as? Node,
let source = dragNode.parent?.representedObject as? Container,
let destIndexPath = dropCoordinator.destinationIndexPath else {
return
}
let isFolderDrop: Bool = {
if coordinator.nodeFor(destIndexPath)?.representedObject is Folder, let propCell = tableView.cellForRow(at: destIndexPath) {
return dropCoordinator.session.location(in: propCell).y >= 0
}
return false
}()
// Based on the drop we have to determine a node to start looking for a parent container.
let destNode: Node? = {
if isFolderDrop {
return coordinator.nodeFor(destIndexPath)
} else {
if destIndexPath.row == 0 {
return coordinator.nodeFor(IndexPath(row: 0, section: destIndexPath.section))
} else if destIndexPath.row > 0 {
return coordinator.nodeFor(IndexPath(row: destIndexPath.row - 1, section: destIndexPath.section))
} else {
return nil
}
}
}()
// Now we start looking for the parent container
let destinationContainer: Container? = {
if let container = (destNode?.representedObject as? Container) ?? (destNode?.parent?.representedObject as? Container) {
return container
} else {
// If we got here, we are trying to drop on an empty section header. Go and find the Account for this section
return coordinator.rootNode.childAtIndex(destIndexPath.section)?.representedObject as? Account
}
}()
guard let destination = destinationContainer, let feed = dragNode.representedObject as? Feed else { return }
if source.account == destination.account {
moveFeedInAccount(feed: feed, sourceContainer: source, destinationContainer: destination)
} else {
moveFeedBetweenAccounts(feed: feed, sourceContainer: source, destinationContainer: destination)
}
}
func moveFeedInAccount(feed: Feed, sourceContainer: Container, destinationContainer: Container) {
guard sourceContainer !== destinationContainer else { return }
BatchUpdate.shared.start()
sourceContainer.account?.moveFeed(feed, from: sourceContainer, to: destinationContainer) { result in
BatchUpdate.shared.end()
switch result {
case .success:
break
case .failure(let error):
self.presentError(error)
}
}
}
func moveFeedBetweenAccounts(feed: Feed, sourceContainer: Container, destinationContainer: Container) {
if let existingFeed = destinationContainer.account?.existingFeed(withURL: feed.url) {
BatchUpdate.shared.start()
destinationContainer.account?.addFeed(existingFeed, to: destinationContainer) { result in
switch result {
case .success:
sourceContainer.account?.removeFeed(feed, from: sourceContainer) { result in
BatchUpdate.shared.end()
switch result {
case .success:
break
case .failure(let error):
self.presentError(error)
}
}
case .failure(let error):
BatchUpdate.shared.end()
self.presentError(error)
}
}
} else {
BatchUpdate.shared.start()
destinationContainer.account?.createFeed(url: feed.url, name: feed.editedName, container: destinationContainer, validateFeed: false) { result in
switch result {
case .success:
sourceContainer.account?.removeFeed(feed, from: sourceContainer) { result in
BatchUpdate.shared.end()
switch result {
case .success:
break
case .failure(let error):
self.presentError(error)
}
}
case .failure(let error):
BatchUpdate.shared.end()
self.presentError(error)
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
//
// RefeshProgressView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/24/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import Account
final class RefreshProgressView: UIView {
@IBOutlet weak var progressView: UIProgressView!
@IBOutlet weak var label: UILabel!
override func awakeFromNib() {
NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .combinedRefreshProgressDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
update()
scheduleUpdateRefreshLabel()
isAccessibilityElement = true
accessibilityTraits = [.updatesFrequently, .notEnabled]
}
func update() {
if !AccountManager.shared.combinedRefreshProgress.isComplete {
progressChanged(animated: false)
} else {
updateRefreshLabel()
}
}
override func didMoveToSuperview() {
progressChanged(animated: false)
}
@objc func progressDidChange(_ note: Notification) {
progressChanged(animated: true)
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
// This hack is probably necessary because custom views in the toolbar don't get
// notifications that the content size changed.
label.font = UIFont.preferredFont(forTextStyle: .footnote)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
// MARK: Private
private extension RefreshProgressView {
func progressChanged(animated: Bool) {
// Layout may crash if not in the view hierarchy.
// https://github.com/Ranchero-Software/NetNewsWire/issues/1764
let isInViewHierarchy = self.superview != nil
let progress = AccountManager.shared.combinedRefreshProgress
if progress.isComplete {
if isInViewHierarchy {
progressView.setProgress(1, animated: animated)
}
func completeLabel() {
// Check that there are no pending downloads.
if AccountManager.shared.combinedRefreshProgress.isComplete {
self.updateRefreshLabel()
self.label.isHidden = false
self.progressView.isHidden = true
if self.superview != nil {
self.progressView.setProgress(0, animated: animated)
}
}
}
if animated {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
completeLabel()
}
} else {
completeLabel()
}
} else {
label.isHidden = true
progressView.isHidden = false
if isInViewHierarchy {
let percent = Float(progress.numberCompleted) / Float(progress.numberOfTasks)
// Don't let the progress bar go backwards unless we need to go back more than 25%
if percent > progressView.progress || progressView.progress - percent > 0.25 {
progressView.setProgress(percent, animated: animated)
}
}
}
}
func updateRefreshLabel() {
if let accountLastArticleFetchEndTime = AccountManager.shared.lastArticleFetchEndTime {
if Date() > accountLastArticleFetchEndTime.addingTimeInterval(60) {
let relativeDateTimeFormatter = RelativeDateTimeFormatter()
relativeDateTimeFormatter.dateTimeStyle = .named
let refreshed = relativeDateTimeFormatter.localizedString(for: accountLastArticleFetchEndTime, relativeTo: Date())
let localizedRefreshText = NSLocalizedString("Updated %@", comment: "Updated")
let refreshText = NSString.localizedStringWithFormat(localizedRefreshText as NSString, refreshed) as String
label.text = refreshText
} else {
label.text = NSLocalizedString("Updated Just Now", comment: "Updated Just Now")
}
} else {
label.text = ""
}
accessibilityLabel = label.text
}
func scheduleUpdateRefreshLabel() {
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
self?.updateRefreshLabel()
self?.scheduleUpdateRefreshLabel()
}
}
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17156" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17125"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="ejl-zC-eNy" customClass="RefreshProgressView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="461" height="90"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" progress="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Ds3-59-ooT" customClass="RoundedProgressView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="180.5" y="42.5" width="100" height="5"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="ReS-sT-7EN"/>
<constraint firstAttribute="height" constant="5" id="oDX-bb-24H"/>
</constraints>
</progressView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7mJ-VZ-zqU">
<rect key="frame" x="214" y="34" width="33" height="22"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="sNo-8i-tO3"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Ds3-59-ooT" firstAttribute="centerX" secondItem="ejl-zC-eNy" secondAttribute="centerX" id="5Rv-6l-HSL"/>
<constraint firstItem="Ds3-59-ooT" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ejl-zC-eNy" secondAttribute="leading" id="Bck-uf-0G7"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="bottom" secondItem="sNo-8i-tO3" secondAttribute="bottom" id="DVn-hI-PhH"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="sNo-8i-tO3" secondAttribute="leading" id="Sbp-yf-ts9"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="centerY" secondItem="ejl-zC-eNy" secondAttribute="centerY" id="Shb-X2-Fwc"/>
<constraint firstItem="7mJ-VZ-zqU" firstAttribute="centerX" secondItem="ejl-zC-eNy" secondAttribute="centerX" id="lFg-fm-YmV"/>
<constraint firstItem="sNo-8i-tO3" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="7mJ-VZ-zqU" secondAttribute="trailing" id="mZ2-XG-Kvg"/>
<constraint firstItem="Ds3-59-ooT" firstAttribute="centerY" secondItem="ejl-zC-eNy" secondAttribute="centerY" id="tIh-lb-KbY"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="Ds3-59-ooT" secondAttribute="trailing" id="vSU-N6-Sk5"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="label" destination="7mJ-VZ-zqU" id="MHr-r4-qop"/>
<outlet property="progressView" destination="Ds3-59-ooT" id="TjM-db-LxM"/>
</connections>
<point key="canvasLocation" x="-75" y="-117"/>
</view>
</objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@@ -0,0 +1,68 @@
//
// ShadowTableChanges.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/20/21.
// Copyright © 2021 Ranchero Software. All rights reserved.
//
import Foundation
struct ShadowTableChanges {
struct Move: Hashable {
var from: Int
var to: Int
init(_ from: Int, _ to: Int) {
self.from = from
self.to = to
}
}
struct RowChanges {
var section: Int
var deletes: Set<Int>?
var inserts: Set<Int>?
var reloads: Set<Int>?
var moves: Set<ShadowTableChanges.Move>?
var isEmpty: Bool {
return (deletes?.isEmpty ?? true) && (inserts?.isEmpty ?? true) && (moves?.isEmpty ?? true)
}
var deleteIndexPaths: [IndexPath]? {
guard let deletes = deletes else { return nil }
return deletes.map { IndexPath(row: $0, section: section) }
}
var insertIndexPaths: [IndexPath]? {
guard let inserts = inserts else { return nil }
return inserts.map { IndexPath(row: $0, section: section) }
}
var reloadIndexPaths: [IndexPath]? {
guard let reloads = reloads else { return nil }
return reloads.map { IndexPath(row: $0, section: section) }
}
var moveIndexPaths: [(IndexPath, IndexPath)]? {
guard let moves = moves else { return nil }
return moves.map { (IndexPath(row: $0.from, section: section), IndexPath(row: $0.to, section: section)) }
}
init(section: Int, deletes: Set<Int>?, inserts: Set<Int>?, reloads: Set<Int>?, moves: Set<Move>?) {
self.section = section
self.deletes = deletes
self.inserts = inserts
self.reloads = reloads
self.moves = moves
}
}
var deletes: Set<Int>?
var inserts: Set<Int>?
var moves: Set<Move>?
var rowChanges: [RowChanges]?
}

View File

@@ -0,0 +1,90 @@
//
// MainTimelineAccessibilityCellLayout.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 4/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
struct MainTimelineAccessibilityCellLayout: MainTimelineCellLayout {
let height: CGFloat
let unreadIndicatorRect: CGRect
let starRect: CGRect
let iconImageRect: CGRect
let titleRect: CGRect
let summaryRect: CGRect
let feedNameRect: CGRect
let dateRect: CGRect
init(width: CGFloat, insets: UIEdgeInsets, cellData: MainTimelineCellData) {
var currentPoint = CGPoint.zero
currentPoint.x = MainTimelineDefaultCellLayout.cellPadding.left + insets.left + MainTimelineDefaultCellLayout.unreadCircleMarginLeft
currentPoint.y = MainTimelineDefaultCellLayout.cellPadding.top
// Unread Indicator and Star
self.unreadIndicatorRect = MainTimelineAccessibilityCellLayout.rectForUnreadIndicator(currentPoint)
self.starRect = MainTimelineAccessibilityCellLayout.rectForStar(currentPoint)
// Start the point at the beginning position of the main block
currentPoint.x += MainTimelineDefaultCellLayout.unreadCircleDimension + MainTimelineDefaultCellLayout.unreadCircleMarginRight
// Icon Image
if cellData.showIcon {
self.iconImageRect = MainTimelineAccessibilityCellLayout.rectForIconView(currentPoint, iconSize: cellData.iconSize)
currentPoint.y = self.iconImageRect.maxY
} else {
self.iconImageRect = CGRect.zero
}
let textAreaWidth = width - (currentPoint.x + MainTimelineDefaultCellLayout.cellPadding.right + insets.right)
// Title Text Block
let (titleRect, numberOfLinesForTitle) = MainTimelineAccessibilityCellLayout.rectForTitle(cellData, currentPoint, textAreaWidth)
self.titleRect = titleRect
// Summary Text Block
if self.titleRect != CGRect.zero {
currentPoint.y = self.titleRect.maxY + MainTimelineDefaultCellLayout.titleBottomMargin
}
self.summaryRect = MainTimelineAccessibilityCellLayout.rectForSummary(cellData, currentPoint, textAreaWidth, numberOfLinesForTitle)
currentPoint.y = [self.titleRect, self.summaryRect].maxY()
if cellData.showFeedName != .none {
self.feedNameRect = MainTimelineAccessibilityCellLayout.rectForFeedName(cellData, currentPoint, textAreaWidth)
currentPoint.y = self.feedNameRect.maxY
} else {
self.feedNameRect = CGRect.zero
}
// Feed Name and Pub Date
self.dateRect = MainTimelineAccessibilityCellLayout.rectForDate(cellData, currentPoint, textAreaWidth)
self.height = self.dateRect.maxY + MainTimelineDefaultCellLayout.cellPadding.bottom
}
}
// MARK: - Calculate Rects
private extension MainTimelineAccessibilityCellLayout {
static func rectForDate(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> CGRect {
var r = CGRect.zero
let size = SingleLineUILabelSizer.size(for: cellData.dateString, font: MainTimelineDefaultCellLayout.dateFont)
r.size = size
r.origin = point
return r
}
}

View File

@@ -0,0 +1,84 @@
//
// MainTimelineCellData.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/6/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import Articles
struct MainTimelineCellData {
private static let noText = NSLocalizedString("(No Text)", comment: "No Text")
let title: String
let attributedTitle: NSAttributedString
let summary: String
let dateString: String
let feedName: String
let byline: String
let showFeedName: ShowFeedName
let iconImage: IconImage? // feed icon, user avatar, or favicon
let showIcon: Bool // Make space even when icon is nil
let read: Bool
let starred: Bool
let numberOfLines: Int
let iconSize: IconSize
init(article: Article, showFeedName: ShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool, numberOfLines: Int, iconSize: IconSize) {
self.title = ArticleStringFormatter.truncatedTitle(article)
self.attributedTitle = ArticleStringFormatter.attributedTruncatedTitle(article)
let truncatedSummary = ArticleStringFormatter.truncatedSummary(article)
if self.title.isEmpty && truncatedSummary.isEmpty {
self.summary = Self.noText
} else {
self.summary = truncatedSummary
}
self.dateString = ArticleStringFormatter.dateString(article.logicalDatePublished)
if let feedName = feedName {
self.feedName = ArticleStringFormatter.truncatedFeedName(feedName)
} else {
self.feedName = ""
}
if let byline = byline {
self.byline = byline
} else {
self.byline = ""
}
self.showFeedName = showFeedName
self.showIcon = showIcon
self.iconImage = iconImage
self.read = article.status.read
self.starred = article.status.starred
self.numberOfLines = numberOfLines
self.iconSize = iconSize
}
init() { // Empty
self.title = ""
self.attributedTitle = NSAttributedString()
self.summary = ""
self.dateString = ""
self.feedName = ""
self.byline = ""
self.showFeedName = .none
self.showIcon = false
self.iconImage = nil
self.read = true
self.starred = false
self.numberOfLines = 0
self.iconSize = .medium
}
}

View File

@@ -0,0 +1,112 @@
//
// MainTimelineCellLayout.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 4/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
protocol MainTimelineCellLayout {
var height: CGFloat {get}
var unreadIndicatorRect: CGRect {get}
var starRect: CGRect {get}
var iconImageRect: CGRect {get}
var titleRect: CGRect {get}
var summaryRect: CGRect {get}
var feedNameRect: CGRect {get}
var dateRect: CGRect {get}
}
extension MainTimelineCellLayout {
static func rectForUnreadIndicator(_ point: CGPoint) -> CGRect {
var r = CGRect.zero
r.size = CGSize(width: MainTimelineDefaultCellLayout.unreadCircleDimension, height: MainTimelineDefaultCellLayout.unreadCircleDimension)
r.origin.x = point.x
r.origin.y = point.y + 5
return r
}
static func rectForStar(_ point: CGPoint) -> CGRect {
var r = CGRect.zero
r.size.width = MainTimelineDefaultCellLayout.starDimension
r.size.height = MainTimelineDefaultCellLayout.starDimension
r.origin.x = floor(point.x - ((MainTimelineDefaultCellLayout.starDimension - MainTimelineDefaultCellLayout.unreadCircleDimension) / 2.0))
r.origin.y = point.y + 3
return r
}
static func rectForIconView(_ point: CGPoint, iconSize: IconSize) -> CGRect {
var r = CGRect.zero
r.size = iconSize.size
r.origin.x = point.x
r.origin.y = point.y + 4
return r
}
static func rectForTitle(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> (CGRect, Int) {
var r = CGRect.zero
if cellData.title.isEmpty {
return (r, 0)
}
r.origin = point
let sizeInfo = MultilineUILabelSizer.size(for: cellData.title, font: MainTimelineDefaultCellLayout.titleFont, numberOfLines: cellData.numberOfLines, width: Int(textAreaWidth))
r.size.width = textAreaWidth
r.size.height = sizeInfo.size.height
if sizeInfo.numberOfLinesUsed < 1 {
r.size.height = 0
}
return (r, sizeInfo.numberOfLinesUsed)
}
static func rectForSummary(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat, _ linesUsed: Int) -> CGRect {
let linesLeft = cellData.numberOfLines - linesUsed
var r = CGRect.zero
if cellData.summary.isEmpty || linesLeft < 1 {
return r
}
r.origin = point
let sizeInfo = MultilineUILabelSizer.size(for: cellData.summary, font: MainTimelineDefaultCellLayout.summaryFont, numberOfLines: linesLeft, width: Int(textAreaWidth))
r.size.width = textAreaWidth
r.size.height = sizeInfo.size.height
if sizeInfo.numberOfLinesUsed < 1 {
r.size.height = 0
}
return r
}
static func rectForFeedName(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> CGRect {
var r = CGRect.zero
r.origin = point
let feedName = cellData.showFeedName == .feed ? cellData.feedName : cellData.byline
let size = SingleLineUILabelSizer.size(for: feedName, font: MainTimelineDefaultCellLayout.feedNameFont)
r.size = size
if r.size.width > textAreaWidth {
r.size.width = textAreaWidth
}
return r
}
}

View File

@@ -0,0 +1,126 @@
//
// MainTimelineDefaultCellLayout.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/6/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import RSCore
struct MainTimelineDefaultCellLayout: MainTimelineCellLayout {
static let cellPadding = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 20)
static let unreadCircleMarginLeft = CGFloat(integerLiteral: 0)
static let unreadCircleDimension = CGFloat(integerLiteral: 12)
static let unreadCircleSize = CGSize(width: MainTimelineDefaultCellLayout.unreadCircleDimension, height: MainTimelineDefaultCellLayout.unreadCircleDimension)
static let unreadCircleMarginRight = CGFloat(integerLiteral: 8)
static let starDimension = CGFloat(integerLiteral: 16)
static let starSize = CGSize(width: MainTimelineDefaultCellLayout.starDimension, height: MainTimelineDefaultCellLayout.starDimension)
static let iconMarginRight = CGFloat(integerLiteral: 8)
static let iconCornerRadius = CGFloat(integerLiteral: 4)
static var titleFont: UIFont {
return UIFont.preferredFont(forTextStyle: .headline)
}
static let titleBottomMargin = CGFloat(integerLiteral: 1)
static var feedNameFont: UIFont {
return UIFont.preferredFont(forTextStyle: .footnote)
}
static let feedRightMargin = CGFloat(integerLiteral: 8)
static var dateFont: UIFont {
return UIFont.preferredFont(forTextStyle: .footnote)
}
static let dateMarginBottom = CGFloat(integerLiteral: 1)
static var summaryFont: UIFont {
return UIFont.preferredFont(forTextStyle: .body)
}
let height: CGFloat
let unreadIndicatorRect: CGRect
let starRect: CGRect
let iconImageRect: CGRect
let titleRect: CGRect
let summaryRect: CGRect
let feedNameRect: CGRect
let dateRect: CGRect
init(width: CGFloat, insets: UIEdgeInsets, cellData: MainTimelineCellData) {
var currentPoint = CGPoint.zero
currentPoint.x = MainTimelineDefaultCellLayout.cellPadding.left + insets.left + MainTimelineDefaultCellLayout.unreadCircleMarginLeft
currentPoint.y = MainTimelineDefaultCellLayout.cellPadding.top
// Unread Indicator and Star
self.unreadIndicatorRect = MainTimelineDefaultCellLayout.rectForUnreadIndicator(currentPoint)
self.starRect = MainTimelineDefaultCellLayout.rectForStar(currentPoint)
// Start the point at the beginning position of the main block
currentPoint.x += MainTimelineDefaultCellLayout.unreadCircleDimension + MainTimelineDefaultCellLayout.unreadCircleMarginRight
// Icon Image
if cellData.showIcon {
self.iconImageRect = MainTimelineDefaultCellLayout.rectForIconView(currentPoint, iconSize: cellData.iconSize)
currentPoint.x = self.iconImageRect.maxX + MainTimelineDefaultCellLayout.iconMarginRight
} else {
self.iconImageRect = CGRect.zero
}
let textAreaWidth = width - (currentPoint.x + MainTimelineDefaultCellLayout.cellPadding.right + insets.right)
// Title Text Block
let (titleRect, numberOfLinesForTitle) = MainTimelineDefaultCellLayout.rectForTitle(cellData, currentPoint, textAreaWidth)
self.titleRect = titleRect
// Summary Text Block
if self.titleRect != CGRect.zero {
currentPoint.y = self.titleRect.maxY + MainTimelineDefaultCellLayout.titleBottomMargin
}
self.summaryRect = MainTimelineDefaultCellLayout.rectForSummary(cellData, currentPoint, textAreaWidth, numberOfLinesForTitle)
var y = [self.titleRect, self.summaryRect].maxY()
if y == 0 {
y = iconImageRect.origin.y + iconImageRect.height
// Necessary calculation of either feed name or date since we are working with dynamic font-sizes
let tmp = MainTimelineDefaultCellLayout.rectForDate(cellData, currentPoint, textAreaWidth)
y -= tmp.height
}
currentPoint.y = y
// Feed Name and Pub Date
self.dateRect = MainTimelineDefaultCellLayout.rectForDate(cellData, currentPoint, textAreaWidth)
let feedNameWidth = textAreaWidth - (MainTimelineDefaultCellLayout.feedRightMargin + self.dateRect.size.width)
self.feedNameRect = MainTimelineDefaultCellLayout.rectForFeedName(cellData, currentPoint, feedNameWidth)
self.height = [self.iconImageRect, self.feedNameRect].maxY() + MainTimelineDefaultCellLayout.cellPadding.bottom
}
}
// MARK: - Calculate Rects
extension MainTimelineDefaultCellLayout {
static func rectForDate(_ cellData: MainTimelineCellData, _ point: CGPoint, _ textAreaWidth: CGFloat) -> CGRect {
var r = CGRect.zero
let size = SingleLineUILabelSizer.size(for: cellData.dateString, font: MainTimelineDefaultCellLayout.dateFont)
r.size = size
r.origin.x = (point.x + textAreaWidth) - size.width
r.origin.y = point.y
return r
}
}

View File

@@ -0,0 +1,314 @@
//
// MainTimelineTableViewCell.swift
// NetNewsWire
//
// Created by Brent Simmons on 8/31/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import UIKit
import RSCore
final class MainTimelineTableViewCell: VibrantTableViewCell {
private let titleView = MainTimelineTableViewCell.multiLineUILabel()
private let summaryView = MainTimelineTableViewCell.multiLineUILabel()
private let unreadIndicatorView = MainUnreadIndicatorView(frame: CGRect.zero)
private let dateView = MainTimelineTableViewCell.singleLineUILabel()
private let feedNameView = MainTimelineTableViewCell.singleLineUILabel()
private lazy var iconView = IconView()
private lazy var starView = {
return NonIntrinsicImageView(image: AppImage.timelineStar)
}()
private var unreadIndicatorPropertyAnimator: UIViewPropertyAnimator?
private var starViewPropertyAnimator: UIViewPropertyAnimator?
var cellData: MainTimelineCellData! {
didSet {
updateSubviews()
}
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override func prepareForReuse() {
unreadIndicatorPropertyAnimator?.stopAnimation(true)
unreadIndicatorPropertyAnimator = nil
unreadIndicatorView.isHidden = true
starViewPropertyAnimator?.stopAnimation(true)
starViewPropertyAnimator = nil
starView.isHidden = true
}
override var frame: CGRect {
didSet {
setNeedsLayout()
}
}
override func updateVibrancy(animated: Bool) {
updateLabelVibrancy(titleView, color: labelColor, animated: animated)
updateLabelVibrancy(summaryView, color: labelColor, animated: animated)
updateLabelVibrancy(dateView, color: secondaryLabelColor, animated: animated)
updateLabelVibrancy(feedNameView, color: secondaryLabelColor, animated: animated)
if animated {
UIView.animate(withDuration: Self.duration) {
if self.isHighlighted || self.isSelected {
self.unreadIndicatorView.backgroundColor = AppColor.vibrantText
} else {
self.unreadIndicatorView.backgroundColor = AppColor.secondaryAccent
}
}
} else {
if self.isHighlighted || self.isSelected {
self.unreadIndicatorView.backgroundColor = AppColor.vibrantText
} else {
self.unreadIndicatorView.backgroundColor = AppColor.secondaryAccent
}
}
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let layout = updatedLayout(width: size.width)
return CGSize(width: size.width, height: layout.height)
}
override func layoutSubviews() {
super.layoutSubviews()
let layout = updatedLayout(width: bounds.width)
unreadIndicatorView.setFrameIfNotEqual(layout.unreadIndicatorRect)
starView.setFrameIfNotEqual(layout.starRect)
iconView.setFrameIfNotEqual(layout.iconImageRect)
setFrame(for: titleView, rect: layout.titleRect)
setFrame(for: summaryView, rect: layout.summaryRect)
feedNameView.setFrameIfNotEqual(layout.feedNameRect)
dateView.setFrameIfNotEqual(layout.dateRect)
separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
func setIconImage(_ image: IconImage) {
iconView.iconImage = image
}
}
// MARK: - Private
private extension MainTimelineTableViewCell {
static func singleLineUILabel() -> UILabel {
let label = NonIntrinsicLabel()
label.lineBreakMode = .byTruncatingTail
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
return label
}
static func multiLineUILabel() -> UILabel {
let label = NonIntrinsicLabel()
label.numberOfLines = 0
label.lineBreakMode = .byTruncatingTail
label.allowsDefaultTighteningForTruncation = false
label.adjustsFontForContentSizeCategory = true
return label
}
func setFrame(for label: UILabel, rect: CGRect) {
if Int(floor(rect.height)) == 0 || Int(floor(rect.width)) == 0 {
hideView(label)
} else {
showView(label)
label.setFrameIfNotEqual(rect)
}
}
func addSubviewAtInit(_ view: UIView, hidden: Bool) {
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = hidden
}
func commonInit() {
addSubviewAtInit(titleView, hidden: false)
addSubviewAtInit(summaryView, hidden: true)
addSubviewAtInit(unreadIndicatorView, hidden: true)
addSubviewAtInit(dateView, hidden: false)
addSubviewAtInit(feedNameView, hidden: true)
addSubviewAtInit(iconView, hidden: true)
addSubviewAtInit(starView, hidden: true)
}
func updatedLayout(width: CGFloat) -> MainTimelineCellLayout {
if UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory {
return MainTimelineAccessibilityCellLayout(width: width, insets: safeAreaInsets, cellData: cellData)
} else {
return MainTimelineDefaultCellLayout(width: width, insets: safeAreaInsets, cellData: cellData)
}
}
func updateTitleView() {
titleView.font = MainTimelineDefaultCellLayout.titleFont
titleView.textColor = labelColor
updateTextFieldAttributedText(titleView, cellData?.attributedTitle)
}
func updateSummaryView() {
summaryView.font = MainTimelineDefaultCellLayout.summaryFont
summaryView.textColor = labelColor
updateTextFieldText(summaryView, cellData?.summary)
}
func updateDateView() {
dateView.font = MainTimelineDefaultCellLayout.dateFont
dateView.textColor = secondaryLabelColor
updateTextFieldText(dateView, cellData.dateString)
}
func updateTextFieldText(_ label: UILabel, _ text: String?) {
let s = text ?? ""
if label.text != s {
label.text = s
setNeedsLayout()
}
}
func updateTextFieldAttributedText(_ label: UILabel, _ text: NSAttributedString?) {
var s = text ?? NSAttributedString(string: "")
if let fieldFont = label.font {
s = s.adding(font: fieldFont)
}
if label.attributedText != s {
label.attributedText = s
setNeedsLayout()
}
}
func updateFeedNameView() {
switch cellData.showFeedName {
case .feed:
showView(feedNameView)
feedNameView.font = MainTimelineDefaultCellLayout.feedNameFont
feedNameView.textColor = secondaryLabelColor
updateTextFieldText(feedNameView, cellData.feedName)
case .byline:
showView(feedNameView)
feedNameView.font = MainTimelineDefaultCellLayout.feedNameFont
feedNameView.textColor = secondaryLabelColor
updateTextFieldText(feedNameView, cellData.byline)
case .none:
hideView(feedNameView)
}
}
func updateUnreadIndicator() {
if !unreadIndicatorView.isHidden && cellData.read && !cellData.starred {
unreadIndicatorPropertyAnimator = UIViewPropertyAnimator(duration: 0.66, curve: .easeInOut) { [weak self] in
self?.unreadIndicatorView.alpha = 0
}
unreadIndicatorPropertyAnimator?.addCompletion { [weak self] _ in
self?.unreadIndicatorView.isHidden = true
self?.unreadIndicatorView.alpha = 1
self?.unreadIndicatorPropertyAnimator = nil
}
unreadIndicatorPropertyAnimator?.startAnimation()
} else {
unreadIndicatorView.alpha = 1
showOrHideView(unreadIndicatorView, cellData.read || cellData.starred)
}
}
func updateStarView() {
if !starView.isHidden && cellData.read && !cellData.starred {
starViewPropertyAnimator = UIViewPropertyAnimator(duration: 0.66, curve: .easeInOut) { [weak self] in
self?.starView.alpha = 0
}
starViewPropertyAnimator?.addCompletion { [weak self] _ in
self?.starView.isHidden = true
self?.starView.alpha = 1
self?.starViewPropertyAnimator = nil
}
starViewPropertyAnimator?.startAnimation()
} else {
starView.alpha = 1
showOrHideView(starView, !cellData.starred)
}
}
func updateIconImage() {
guard let image = cellData.iconImage, cellData.showIcon else {
makeIconEmpty()
return
}
showView(iconView)
if iconView.iconImage !== cellData.iconImage {
iconView.iconImage = image
setNeedsLayout()
}
}
func updateAccessiblityLabel() {
let starredStatus = cellData.starred ? "\(NSLocalizedString("Starred", comment: "Starred article for accessibility")), " : ""
let unreadStatus = cellData.read ? "" : "\(NSLocalizedString("Unread", comment: "Unread")), "
let label = starredStatus + unreadStatus + "\(cellData.feedName), \(cellData.title), \(cellData.summary), \(cellData.dateString)"
accessibilityLabel = label
}
func makeIconEmpty() {
if iconView.iconImage != nil {
iconView.iconImage = nil
setNeedsLayout()
}
hideView(iconView)
}
func hideView(_ view: UIView) {
if !view.isHidden {
view.isHidden = true
}
}
func showView(_ view: UIView) {
if view.isHidden {
view.isHidden = false
}
}
func showOrHideView(_ view: UIView, _ shouldHide: Bool) {
if shouldHide {
hideView(view)
} else {
showView(view)
}
}
func updateSubviews() {
updateTitleView()
updateSummaryView()
updateDateView()
updateFeedNameView()
updateUnreadIndicator()
updateStarView()
updateIconImage()
updateAccessiblityLabel()
}
}

View File

@@ -0,0 +1,19 @@
//
// MainUnreadIndicatorView.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/16/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import UIKit
final class MainUnreadIndicatorView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = frame.size.width / 2.0
clipsToBounds = true
}
}

View File

@@ -0,0 +1,181 @@
//
// UILabelSizerSpecifier.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/19/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import UIKit
// Get the height of an NSTextField given a string, font, and width.
// Uses a cache. Avoids actually measuring text as much as possible.
// Main thread only.
typealias WidthHeightCache = [Int: Int] // width: height
private struct UILabelSizerSpecifier: Hashable {
let numberOfLines: Int
let font: UIFont
}
struct TextFieldSizeInfo {
let size: CGSize // Integral size (ceiled)
let numberOfLinesUsed: Int // A two-line text field may only use one line, for instance. This would equal 1, then.
}
final class MultilineUILabelSizer {
private let numberOfLines: Int
private let font: UIFont
private let singleLineHeightEstimate: Int
private let doubleLineHeightEstimate: Int
private var cache = [String: WidthHeightCache]() // Each string has a cache.
private static var sizers = [UILabelSizerSpecifier: MultilineUILabelSizer]()
private init(numberOfLines: Int, font: UIFont) {
self.numberOfLines = numberOfLines
self.font = font
self.singleLineHeightEstimate = MultilineUILabelSizer.calculateHeight("AqLjJ0/y", 200, font)
self.doubleLineHeightEstimate = MultilineUILabelSizer.calculateHeight("AqLjJ0/y\nAqLjJ0/y", 200, font)
}
static func size(for string: String, font: UIFont, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width)
}
static func emptyCache() {
sizers = [UILabelSizerSpecifier: MultilineUILabelSizer]()
}
}
// MARK: - Private
private extension MultilineUILabelSizer {
static func sizer(numberOfLines: Int, font: UIFont) -> MultilineUILabelSizer {
let specifier = UILabelSizerSpecifier(numberOfLines: numberOfLines, font: font)
if let cachedSizer = sizers[specifier] {
return cachedSizer
}
let newSizer = MultilineUILabelSizer(numberOfLines: numberOfLines, font: font)
sizers[specifier] = newSizer
return newSizer
}
func sizeInfo(for string: String, width: Int) -> TextFieldSizeInfo {
let textFieldHeight = height(for: string, width: width)
let numberOfLinesUsed = numberOfLines(for: textFieldHeight)
let size = CGSize(width: width, height: textFieldHeight)
let sizeInfo = TextFieldSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
return sizeInfo
}
func height(for string: String, width: Int) -> Int {
if cache[string] == nil {
cache[string] = WidthHeightCache()
}
if let height = cache[string]![width] {
return height
}
if let height = heightConsideringNeighbors(cache[string]!, width) {
return height
}
var height = MultilineUILabelSizer.calculateHeight(string, width, font)
if numberOfLines != 0 {
let maxHeight = singleLineHeightEstimate * numberOfLines
if height > maxHeight {
height = maxHeight
}
}
cache[string]![width] = height
return height
}
static func calculateHeight(_ string: String, _ width: Int, _ font: UIFont) -> Int {
let height = string.height(withConstrainedWidth: CGFloat(width), font: font)
return Int(ceil(height))
}
func numberOfLines(for height: Int) -> Int {
// Well have to see if this really works reliably.
let averageHeight = CGFloat(doubleLineHeightEstimate) / 2.0
let lines = Int(round(CGFloat(height) / averageHeight))
return lines
}
func heightIsProbablySingleLineHeight(_ height: Int) -> Bool {
return heightIsProbablyEqualToEstimate(height, singleLineHeightEstimate)
}
func heightIsProbablyDoubleLineHeight(_ height: Int) -> Bool {
return heightIsProbablyEqualToEstimate(height, doubleLineHeightEstimate)
}
func heightIsProbablyEqualToEstimate(_ height: Int, _ estimate: Int) -> Bool {
let slop = 4
let minimum = estimate - slop
let maximum = estimate + slop
return height >= minimum && height <= maximum
}
func heightConsideringNeighbors(_ heightCache: WidthHeightCache, _ width: Int) -> Int? {
// Given width, if the height at width - something and width + something is equal,
// then that height must be correct for the given width.
// Also:
// If a narrower neighbors height is single line height, then this wider width must also be single-line height.
// If a wider neighbors height is double line height, and numberOfLines == 2, then this narrower width must able be double-line height.
var smallNeighbor = (width: 0, height: 0)
var largeNeighbor = (width: 0, height: 0)
for (oneWidth, oneHeight) in heightCache {
if oneWidth < width && heightIsProbablySingleLineHeight(oneHeight) {
return oneHeight
}
if numberOfLines == 2 && oneWidth > width && heightIsProbablyDoubleLineHeight(oneHeight) {
return oneHeight
}
if oneWidth < width && (oneWidth > smallNeighbor.width || smallNeighbor.width == 0) {
smallNeighbor = (oneWidth, oneHeight)
} else if oneWidth > width && (oneWidth < largeNeighbor.width || largeNeighbor.width == 0) {
largeNeighbor = (oneWidth, oneHeight)
}
if smallNeighbor.width != 0 && smallNeighbor.height == largeNeighbor.height {
return smallNeighbor.height
}
}
return nil
}
}

View File

@@ -0,0 +1,64 @@
//
// SingleLineUILabelSizer.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/19/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import UIKit
// Get the size of an UILabel configured with a specific font with a specific size.
// Uses a cache.
// Main thready only.
final class SingleLineUILabelSizer {
let font: UIFont
private var cache = [String: CGSize]()
init(font: UIFont) {
self.font = font
}
func size(for text: String) -> CGSize {
if let cachedSize = cache[text] {
return cachedSize
}
let height = text.height(withConstrainedWidth: .greatestFiniteMagnitude, font: font)
let width = text.width(withConstrainedHeight: .greatestFiniteMagnitude, font: font)
let calculatedSize = CGSize(width: ceil(width), height: ceil(height))
cache[text] = calculatedSize
return calculatedSize
}
static private var sizers = [UIFont: SingleLineUILabelSizer]()
static func sizer(for font: UIFont) -> SingleLineUILabelSizer {
if let cachedSizer = sizers[font] {
return cachedSizer
}
let newSizer = SingleLineUILabelSizer(font: font)
sizers[font] = newSizer
return newSizer
}
// Use this call. Its easiest.
static func size(for text: String, font: UIFont) -> CGSize {
return sizer(for: font).size(for: text)
}
static func emptyCache() {
sizers = [UIFont: SingleLineUILabelSizer]()
}
}

View File

@@ -0,0 +1,16 @@
//
// MainTimelineDataSource.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 8/30/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
final class MainTimelineDataSource<SectionIdentifierType, ItemIdentifierType>: UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> where SectionIdentifierType: Hashable, ItemIdentifierType: Hashable {
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
}

View File

@@ -0,0 +1,56 @@
//
// MainTimelineTitleView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/21/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
final class MainTimelineTitleView: UIView {
@IBOutlet weak var iconView: IconView!
@IBOutlet weak var label: UILabel!
@IBOutlet weak var unreadCountView: MainTimelineUnreadCountView!
private lazy var pointerInteraction: UIPointerInteraction = {
UIPointerInteraction(delegate: self)
}()
override var accessibilityLabel: String? {
get {
if let name = label.text {
let unreadLabel = NSLocalizedString("unread", comment: "Unread label for accessibility")
return "\(name) \(unreadCountView.unreadCount) \(unreadLabel)"
} else {
return nil
}
}
set {
}
}
func buttonize() {
heightAnchor.constraint(equalToConstant: 40.0).isActive = true
accessibilityTraits = .button
addInteraction(pointerInteraction)
}
func debuttonize() {
heightAnchor.constraint(equalToConstant: 40.0).isActive = true
accessibilityTraits.remove(.button)
removeInteraction(pointerInteraction)
}
}
extension MainTimelineTitleView: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
var rect = self.frame
rect.origin.x -= 10
rect.size.width += 20
return UIPointerStyle(effect: .automatic(UITargetedPreview(view: self)), shape: .roundedRect(rect))
}
}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="MainTimelineTitleView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="190" height="38"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="250" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="5F6-2v-qSS">
<rect key="frame" x="28" y="9" width="43.5" height="20"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="z9o-XA-3t4" customClass="MainTimelineUnreadCountView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="79.5" y="9" width="110.5" height="20"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5gI-Wl-lnK" customClass="IconView" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="9" width="20" height="20"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="20" id="TgA-1l-WQJ"/>
<constraint firstAttribute="height" constant="20" id="VUB-ip-zXU"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="5gI-Wl-lnK" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="1Yh-ha-Pu8"/>
<constraint firstItem="5F6-2v-qSS" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="CeZ-D5-NOV"/>
<constraint firstItem="z9o-XA-3t4" firstAttribute="leading" secondItem="5F6-2v-qSS" secondAttribute="trailing" constant="8" symbolic="YES" id="CiV-5P-T1S"/>
<constraint firstAttribute="trailing" secondItem="z9o-XA-3t4" secondAttribute="trailing" id="OVL-Ac-Rtt"/>
<constraint firstItem="z9o-XA-3t4" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="ZkY-jG-eZO"/>
<constraint firstItem="5gI-Wl-lnK" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="eQU-mX-qmd"/>
<constraint firstItem="5F6-2v-qSS" firstAttribute="leading" secondItem="5gI-Wl-lnK" secondAttribute="trailing" constant="8" id="fVr-vW-alg"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="iconView" destination="5gI-Wl-lnK" id="IiR-qS-d22"/>
<outlet property="label" destination="5F6-2v-qSS" id="ec7-8Y-PRv"/>
<outlet property="unreadCountView" destination="z9o-XA-3t4" id="JBy-aa-feL"/>
</connections>
<point key="canvasLocation" x="14.492753623188406" y="-224.33035714285714"/>
</view>
</objects>
</document>

View File

@@ -0,0 +1,39 @@
//
// MainTimelineUnreadCountView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/30/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
final class MainTimelineUnreadCountView: MainFeedUnreadCountView {
override var padding: UIEdgeInsets {
return UIEdgeInsets(top: 2.0, left: 9.0, bottom: 2.0, right: 9.0)
}
override var textColor: UIColor {
return UIColor.systemBackground
}
override var intrinsicContentSize: CGSize {
return contentSize
}
override func draw(_ dirtyRect: CGRect) {
let cornerRadii = CGSize(width: cornerRadius, height: cornerRadius)
let rect = CGRect(x: 1, y: 1, width: bounds.width - 2, height: bounds.height - 2)
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: cornerRadii)
AppColor.accent.setFill()
path.fill()
if unreadCount > 0 {
unreadCountString.draw(at: textRect().origin, withAttributes: textAttributes)
}
}
}

View File

@@ -0,0 +1,85 @@
//
// UndoAvailableAlertController.swift
// NetNewsWire
//
// Created by Phil Viso on 9/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import UIKit
protocol MarkAsReadAlertControllerSourceType {}
extension CGRect: MarkAsReadAlertControllerSourceType {}
extension UIView: MarkAsReadAlertControllerSourceType {}
extension UIBarButtonItem: MarkAsReadAlertControllerSourceType {}
struct MarkAsReadAlertController {
static func confirm<T>(
_ controller: UIViewController?,
coordinator: SceneCoordinator?,
confirmTitle: String,
sourceType: T,
cancelCompletion: (() -> Void)? = nil,
completion: @escaping () -> Void
) where T: MarkAsReadAlertControllerSourceType {
guard let controller = controller, let coordinator = coordinator else {
completion()
return
}
if AppDefaults.confirmMarkAllAsRead {
let alertController = MarkAsReadAlertController.alert(coordinator: coordinator, confirmTitle: confirmTitle, cancelCompletion: cancelCompletion, sourceType: sourceType) { _ in
completion()
}
controller.present(alertController, animated: true)
} else {
completion()
}
}
private static func alert<T>(
coordinator: SceneCoordinator,
confirmTitle: String,
cancelCompletion: (() -> Void)?,
sourceType: T,
completion: @escaping (UIAlertAction) -> Void
) -> UIAlertController where T: MarkAsReadAlertControllerSourceType {
let title = NSLocalizedString("Mark As Read", comment: "Mark As Read")
let message = NSLocalizedString("You can turn this confirmation off in Settings.",
comment: "You can turn this confirmation off in Settings.")
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
let settingsTitle = NSLocalizedString("Open Settings", comment: "Open Settings")
let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) { _ in
cancelCompletion?()
}
let settingsAction = UIAlertAction(title: settingsTitle, style: .default) { _ in
coordinator.showSettings(scrollToArticlesSection: true)
}
let markAction = UIAlertAction(title: confirmTitle, style: .default, handler: completion)
alertController.addAction(markAction)
alertController.addAction(settingsAction)
alertController.addAction(cancelAction)
if let barButtonItem = sourceType as? UIBarButtonItem {
alertController.popoverPresentationController?.barButtonItem = barButtonItem
}
if let rect = sourceType as? CGRect {
alertController.popoverPresentationController?.sourceRect = rect
}
if let view = sourceType as? UIView {
alertController.popoverPresentationController?.sourceView = view
}
return alertController
}
}

File diff suppressed because it is too large Load Diff