GithubHelp home page GithubHelp logo

wiki's Introduction

wiki's People

Contributors

popeyelau avatar

Stargazers

 avatar  avatar

Watchers

 avatar  avatar

wiki's Issues

Dart Collection

spread

Widget buildItems() {
  List<Widget> sectionA = [Text("A")];
  List<Widget> sectionB = [Text("B")];
  return Column(
    children: [
      ...sectionA,
      ...sectionB,
    ],
  );
}

if

Widget buildItems() {
  List<Widget> sectionA = [Text("A")];
  List<Widget> sectionB = [Text("B")];
  bool showSectionB = false;
  return Column(
    children: [
      ...sectionA,
      if (showSectionB) ...sectionB,
    ],
  );
}

for in

  Widget buildItems() {
    List<Widget> sectionA = [Text("A")];
    List<Widget> sectionB = [Text("B")];
    List<int> sectionC = [1, 2, 3, 4, 5];

    bool showSectionB = false;
    return Column(
      children: [
        ...sectionA,
        if (showSectionB) ...sectionB,
        for (var number in sectionC)
          if (number.isEven) Text(number.toString())
      ],
    );
  }

备忘

  • cloudflared 使用 http2 协议
$ sudo vim /Library/LaunchDaemons/com.cloudflare.cloudflared.plist
<plist version="1.0">
    <dict>
        <key>Label</key>
        <string>com.cloudflare.cloudflared</string>
        <key>ProgramArguments</key>
        <array>
            <string>/opt/homebrew/bin/cloudflared</string>
            <string>--protocol</string>
            <string>http2</string>
            <string>tunnel</string>
            <string>run</string>
            <string>--token</string>
            <string>TOKEN VALUE </string>
        </array>
$ sudo launchctl load /Library/LaunchDaemons/com.cloudflare.cloudflared.plist
$ sudo launchctl start com.cloudflare.cloudflared

https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/remote-management/

Swift Tips & Extensions

  • Hex Color String To UIColor
extension String {
    var hexColor: UIColor {
        let hex = trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int = UInt32()
        Scanner(string: hex).scanHexInt32(&int)
        let a, r, g, b: UInt32
        switch hex.count {
        case 3: // RGB (12-bit)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32-bit)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            return .clear
        }
        return UIColor(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
    }
}

Shorebird - Flutter Code Push

https://docs.shorebird.dev/status
https://github.com/shorebirdtech

shorebird.yaml

app_id: xxxxxxxxxxxxxx
flavors:
  internal: xxxxxxxxxxxx
  stable: xxxxxxxxxxxx
base_url: https://api.shorebird.dev
auto_update: false

// init shorebird
https://github.com/shorebirdtech/engine/blob/shorebird/dev/shell/common/shorebird.cc#L42
https://github.com/shorebirdtech/engine/blob/shorebird/dev/shell/common/shorebird.cc#L73

// init updater
https://github.com/shorebirdtech/updater/blob/main/library/src/c_api.rs#L104
https://github.com/shorebirdtech/updater/blob/main/library/src/updater.rs#L106

// shorebird_start_update_thread();
https://github.com/shorebirdtech/engine/blob/shorebird/dev/shell/common/shorebird.cc#L107

https://github.com/shorebirdtech/updater/blob/main/library/src/updater.rs#L271
https://github.com/shorebirdtech/updater/blob/main/library/src/cache.rs#L316

Android

https://github.com/shorebirdtech/engine/blob/shorebird/dev/shell/platform/android/flutter_main.cc#L134

iOS

// libupdater.a
https://github.com/shorebirdtech/engine/blob/shorebird/dev/shell/platform/darwin/ios/BUILD.gn#L202

// settings.application_library_path
https://github.com/shorebirdtech/engine/blob/shorebird/dev/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm#L222

// download patch
https://github.com/shorebirdtech/updater/blob/main/library/src/updater.rs#L206

// restart with shorebird_next_boot_patch_path
https://github.com/shorebirdtech/engine/blob/shorebird/dev/shell/common/shorebird.cc#L96

// Hacks for iOS demo
shorebirdtech/engine@12c4135
https://github.com/shorebirdtech/engine/blob/12c4135b5c90854fb9efc360c0b3ac25d37e9f3f/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm#L229

directory structure

AppData
    ├── AppDataInfo.plist
    ├── Documents
    ├── Library
    │   ├── Application Support
    │   │   └── shorebird
    │   │       └── shorebird_updater
    │   │           ├── downloads
    │   │           │   ├── 1
    │   │           │   └── 2
    │   │           ├── slot_0
    │   │           │   └── dlc.vmcode
    │   │           ├── slot_1
    │   │           │   └── dlc.vmcode
    │   │           └── state.json

state.json

{
 "cache_dir": "/private/var/mobile/Containers/Data/Application/9A5801A8-510F-48FD-A65C-A398F5D64591/Library/Application Support/shorebird/shorebird_updater",
 "release_version": "1.0.0+1",
 "failed_patches": [],
 "successful_patches": [],
 "current_boot_slot_index": 1,
 "next_boot_slot_index": 1,
 "slots": [
   {
     "patch_number": 1
   },
   {
     "patch_number": 2
   }
 ]
}

get patches

// check patches
curl -X "POST" "https://api.shorebird.dev/api/v1/patches/check" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "platform": "ios",
  "arch": "aarch64",
  "release_version": "1.0.0+1",
  "app_id": "xxxxxxxxxxxxxxxxx",
  "channel": "stable",
  "patch_number": 1
}'

iOS 模拟器相关命令

# 查看设备列表
xcrun simctl list

# 修改模拟器时间等信息(截图用)
xcrun simctl status_bar <device> override --time 12:00 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100

# 删除不可用的设备
xcrun simctl delete unavailable

# 卸载应用
xcrun simctl uninstall <device> <app bundle identifier>

# 打开应用
xcrun simctl launch <device> <app bundle identifier>

# 关闭应用
xcrun simctl terminate <device> <app bundle identifier>

快速使用 goquery 抓取网页内容 并转换成 json 输出

参考资料

goquery
Golang 中使用 JSON 的一些小技巧

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"

	"github.com/PuerkitoBio/goquery"
)

type VidoeType int

const (
	VIDEO_FILM VidoeType = iota + 1
	VIDEO_DRAMA
	VIDEO_CARTOON
	VIDEO_VARIETY
)

type SortType int

func (this SortType) String() string {
	switch this {
	case SORT_BY_PLAY:
		return "play"
	case SORT_BY_UPDATE:
		return "update"
	case SORT_BY_LIKE:
		return "like"
	default:
		return "play"
	}
}

const (
	SORT_BY_PLAY SortType = iota + 1
	SORT_BY_UPDATE
	SORT_BY_LIKE
)

const (
	HOST string = "https://i.hyys.me"
)

func main() {
	home()
	video("42768")
	videos(VIDEO_FILM, SORT_BY_PLAY, 0)
}

// 首页
func home() {
	requestURL := fmt.Sprintf("%v/webIndex.php", HOST)
	doc, err := getDocument(requestURL)
	if err != nil {
		fmt.Println(err)
	}
	sectionDivs := doc.Find(".hideNavBar>div[class=container]")
	sections := make([]*Section, sectionDivs.Length())
	sectionDivs.Each(func(i int, s *goquery.Selection) {
		section := &Section{}
		section.Title = s.Find("p>a:first-of-type").Text()
		videoCards := s.Find(".videoContent>div[class=videoCard]")
		section.Items = parseVideos(videoCards)
		sections[i] = section
	})

	json, err := marshalIndent(sections)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(*json)
}

// 获取视频详情
func video(id string) {
	requestURL := fmt.Sprintf("%v/webIndex.php/?code=videoDetail&vod_id=%v", HOST, id)
	doc, err := getDocument(requestURL)
	if err != nil {
		fmt.Println(err)
		return
	}
	episodeHrefs := doc.Find(".dramaSeriesCont>a")
	episodes := make([]*Episode, episodeHrefs.Length())
	episodeHrefs.Each(func(i int, s *goquery.Selection) {
		episode := &Episode{}
		episode.Name = s.Text()
		if src, ok := s.Attr("data-href"); ok {
			episode.URL = src
		}
		episodes[i] = episode
	})

	json, err := marshalIndent(episodes)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(*json)
}

// 获取视频列表
func videos(typeID VidoeType, sort SortType, page int) {
	if page < 0 {
		page = 0
	}
	requestURL := fmt.Sprintf("%v/webIndex.php/?code=search&type=%d&most=%s&page=%d", HOST, typeID, sort, page)
	doc, err := getDocument(requestURL)
	if err != nil {
		fmt.Println(err)
		return
	}
	videoCards := doc.Find(".videoContent>div[class=videoCard]")
	videos := parseVideos(videoCards)
	json, err := marshalIndent(videos)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(*json)
}

// 解析视频 dom
func parseVideos(s *goquery.Selection) []*Video {
	videos := make([]*Video, s.Length())
	s.Each(func(i int, s *goquery.Selection) {
		video := &Video{}
		if src, ok := s.Attr("data-video-url"); ok {
			video.URL = src
			url, _ := (url.ParseRequestURI(src))
			video.ID = url.Query().Get("vod_id")
		}
		if cover, ok := s.Find(".videoCover").Attr("src"); ok {
			video.Cover = cover
		}
		video.Name = s.Find(".videoInfo>p[class=videoName]").Text()
		video.Views = s.Find(".videoInfo>div[class=jusBetween]>p").Text()
		videos[i] = video
	})
	return videos
}

// 转换成 json 格式
func marshalIndent(source interface{}) (*string, error) {
	bytes, err := json.MarshalIndent(source, "", "  ")
	if err != nil {
		fmt.Println(err)
		return nil, err
	}
	json := string(bytes)
	return &json, nil
}

// 获取页面内容
func getDocument(url string) (*goquery.Document, error) {
	client := &http.Client{}
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36")
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	doc, err := goquery.NewDocumentFromResponse(resp)
	if err != nil {
		return nil, err
	}
	return doc, nil
}

type Section struct {
	Title string   `json:"title"`
	Items []*Video `json:"items"`
}

type Video struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	URL   string `json:"-"` //不输出
	Cover string `json:"cover"`
	Views string `json:"views"`
}

type Episode struct {
	Name string `json:"name"`
	URL  string `json:"url"`
}

ViewStyle

import UIKit
import PlaygroundSupport


protocol Stylable {
    init()
}

extension Stylable {
    init(style: ViewStyle<Self>) {
        self.init()
        apply(style)
    }

    func apply(_ style: ViewStyle<Self>) -> Self {
        style.style(self)
        return self
    }
}


struct ViewStyle<T> {
    let style: (T) -> Void

    static func +(lhs: ViewStyle<T>, rhs: ViewStyle<T>) -> ViewStyle<T> {
        return ViewStyle<T> {
            lhs.style($0)
            rhs.style($0)
        }
    }
}


extension UIView: Stylable {}

extension ViewStyle where T: UIButton {

    static var filled: ViewStyle<UIButton> {
        return ViewStyle<UIButton> {
            $0.setTitleColor(.white, for: .normal)
            $0.backgroundColor = .red
        }
    }

    static var rounded: ViewStyle<UIButton> {
        return ViewStyle<UIButton> {
            $0.layer.cornerRadius = 4.0
        }
    }

    static var subtitle: ViewStyle<UIButton> {
        return ViewStyle<UIButton> {
            $0.titleLabel?.font = .systemFont(ofSize: 12)
            $0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
        }
    }

    static var roundedAndFilled: ViewStyle<UIButton> {
        return rounded + filled
    }

    static func title(_ title: String?, for state: UIControl.State) -> ViewStyle<UIButton> {
        return ViewStyle<UIButton> {
            $0.setTitle(title, for: state)
            $0.sizeToFit()
        }
    }
}




// Use
let button = UIButton(style: .roundedAndFilled)
    .apply(.subtitle)
    .apply(.title("Hello World", for: .normal))


PlaygroundPage.current.liveView = button

Flutter 多设备调试

  • 获取已连接设备
$ flutter devices

4 connected devices:

SM G8870 (mobile)      • 31814575ff1d7ece                     • android-arm64  • Android 10 (API 29)
iPhone 11 (mobile)     • 4B94904E-0460-4D06-93CD-43AA879D5C03 • ios            • com.apple.CoreSimulator.SimRuntime.iOS-14-5
(simulator)
iPhone 11 Pro (mobile) • 9E33CF02-924A-4161-B5D4-E6D0AF58217E • ios            • com.apple.CoreSimulator.SimRuntime.iOS-14-5
(simulator)
Chrome (web)           • chrome                               • web-javascript • Google Chrome 91.0.4472.114
  • 配置 launch.json
{
    "version": "0.2.0",
    "compounds": [{
        "name": "Multiple Device Preview",
        "request": "launch",
        "type": "dart",
        "args": [
            "--dart-define",
            "APP_CHANNEL=Debug",
            "--dart-define",
            "HTTP_PROXY=172.21.110.101:9999",
            "--dart-define",
            "DEBUG_ENABLE=true",
            "--dart-define",
            "API_ENV=0"
        ],
        "configurations": ["iPhone 11", "iPhone 11 Pro", "SM G8870"],
    }],
    "configurations": [
        {
            "name": "Debug",
            "request": "launch",
            "type": "dart",
            "args": [
                "--dart-define",
                "APP_CHANNEL=Debug",
                "--dart-define",
                "HTTP_PROXY=172.21.110.101:9999",
                "--dart-define",
                "DEBUG_ENABLE=true",
                "--dart-define",
                "API_ENV=0"
            ]
        },
        {
            "name": "iPhone 11",
            "request": "launch",
            "type": "dart",
            "deviceId": "4B94904E-0460-4D06-93CD-43AA879D5C03",
            "args": [
                "--dart-define",
                "APP_CHANNEL=Debug",
                "--dart-define",
                "HTTP_PROXY=172.21.110.101:9999",
                "--dart-define",
                "DEBUG_ENABLE=true",
                "--dart-define",
                "API_ENV=0"
            ]
        },
        {
            "name": "iPhone 11 Pro",
            "request": "launch",
            "type": "dart",
            "deviceId": "9E33CF02-924A-4161-B5D4-E6D0AF58217E",
            "args": [
                "--dart-define",
                "APP_CHANNEL=Debug",
                "--dart-define",
                "HTTP_PROXY=172.21.110.101:9999",
                "--dart-define",
                "DEBUG_ENABLE=true",
                "--dart-define",
                "API_ENV=0"
            ]
        },
        {
            "name": "SM G8870",
            "request": "launch",
            "type": "dart",
            "deviceId": "31814575ff1d7ece",
            "args": [
                "--dart-define",
                "APP_CHANNEL=Debug",
                "--dart-define",
                "HTTP_PROXY=172.21.110.101:9999",
                "--dart-define",
                "DEBUG_ENABLE=true",
                "--dart-define",
                "API_ENV=0"
            ]
        },
        {
            "name": "Pro(profile)",
            "request": "launch",
            "type": "dart",
            "flutterMode": "profile",
            "args": [
                "--dart-define",
                "APP_CHANNEL=Debug",
                "--dart-define",
                "API_ENV=0",
                "--verbose"
            ]
        },
        {
            "name": "Daily",
            "request": "launch",
            "type": "dart",
            "args": [
                "--dart-define",
                "APP_CHANNEL=Debug",
                "--dart-define",
                "API_ENV=2",
                "--dart-define",
                "HTTP_PROXY=172.21.110.101:9999",
            ]
        },
        {
            "name": "UAT",
            "request": "launch",
            "type": "dart",
            "args": [
                "--dart-define",
                "APP_CHANNEL=Debug",
                "--dart-define",
                "API_ENV=3",
                "--dart-define",
                "HTTP_PROXY=172.21.110.101:9999",
            ]
        },
    ],

}

Swift 格式化数字

import Foundation

extension Formatter {
    static let number = NumberFormatter()
}

extension FloatingPoint {
    func fractionDigits(min: Int = 2, max: Int = 2, roundingMode: NumberFormatter.RoundingMode = .down) -> String {
        Formatter.number.minimumFractionDigits = min
        Formatter.number.maximumFractionDigits = max
        Formatter.number.roundingMode = roundingMode
        Formatter.number.numberStyle = .decimal
        return Formatter.number.string(for: self) ?? ""
    }
}

extension String {
    var doubleValue: Double? {
        return Formatter.number.number(from: self)?.doubleValue
    }
    var intValue: Int?  {
        return Formatter.number.number(from: self)?.intValue
    }
    var floatValue: Float? {
        return Formatter.number.number(from: self)?.floatValue
    }
}

let num = 1.4988
num.fractionDigits() //1.49
num.fractionDigits(min: 2, max: 3, roundingMode: .down) //1.498
num.fractionDigits(min: 2, max: 3, roundingMode: .up) //1.499

let str = "123.88"
str.doubleValue //123.88
str.intValue //123
str.floatValue //123.88
str.floatValue?.fractionDigits(min: 1, max: 1, roundingMode: .up) //123.9

Tuple & Either & Option

Tuple

const t = const Tuple2<String, int>('a', 10);

print(t.item1); // prints 'a'
print(t.item2); // prints '10'

Either & Option

 ///Either
 Either<SomeException, int> testEither(int code) {
    if (code >= 100) return Right(200);
    return Left(SomeException("不能小于 100"));
  }

 final result = testEither(100);
 print(result.isRight); //true
 print(result.isLeft); //false;
 result.fold((lhs) => print(lhs), (rhs) => print(rhs));

基于 Alamofire 实现简单路由

import Alamofire

protocol APIConfiguration: URLRequestConvertible {
    var method: HTTPMethod { get }
    var path: String { get }
    var parameters: Parameters? { get }
}


enum Router: APIConfiguration {
    case topics
    case search(keywords: String, page: Int)
    case resource(id: String)

    var method: HTTPMethod {
        return .get
    }
    
    var path: String {
        switch self {
        case .topics:
            return "/topics"
        case .search:
            return "/search"
        case .resource(let id):
            return "/resource/\(id)"
        }
    }

    var parameters: Parameters? {
        switch self {
        case .search(let keywords, let page):
            return ["wd": keywords, "p": page]
        default:
            return nil
        }

    }
    
    var encoding: ParameterEncoding {
        return URLEncoding.default
    }
    
    func asURLRequest() throws -> URLRequest {
        let url = try ENV.host.asURL()
        var request = URLRequest(url: url.appendingPathComponent(path))
        
        request.httpMethod = method.rawValue
        return try encoding.encode(request, with: parameters)
    }
}
//使用
 Alamofire.request(Router.topics)...

参考资料
Write a Networking Layer in Swift 4 using Alamofire 5 and Codable Part 1: API Router

Dart String remove emojis

void main() {
  final result =
      formatText("123你好 hello world 😞😞😍🤩🥳😇4😰🤯5🥱6🤐🤑🤧🤒🎃🦾😰😒😜");
  print(result);
}

String formatText(String str) {
  final RegExp regExp = RegExp(
      r'(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])');

  if (str.contains(regExp)) {
    str = str.replaceAll(regExp, '');
  }

  return str;
}

Run on DartPad

Higher Order Function in Swift

//map
let strings = (1...5).map(String.init) //same as .map { String($0) }
print(strings) //["1", "2", "3", "4", "5"]

//filter
let names = ["Popeye", "Oyl", "Edison"].filter { $0.count > 3 }
print(names) //["Popeye", "Edison"]

//compactMap
let numbers = ["1", "2", "hello"].compactMap { Int($0) }
print(numbers) //[1, 2]


//flatMap
let flat = [[1,2,3], [4,5,6]].flatMap { $0 }
print(flat) //[1, 2, 3, 4, 5, 6]

//sorted
let sorted = flat.sorted(by: >)
print(sorted) //[6, 5, 4, 3, 2, 1]

//reduce
let sayHello =  ["h", "e", "l", "l", "o"].reduce("Say ", +)
print(sayHello) // Say hello

let sum = [1,2,3,4,5].reduce(0, +)
print(sum) //15

//allSatisfy
print("allSatisfy: ", [1,2,3,4,5].allSatisfy { $0 > 3}) //allSatisfy:  false

Golang 打包&压缩

打包&压缩

Linux 64

GOOS=linux GOARCH=amd64 go build -ldflags '-w -s' -o dogetv-cli dogetv-cli.go && upx ./dogetv-cli

Windows 64

GOOS=windows GOARCH=amd64 go build -ldflags '-w -s' -o dogetv-cli.exe dogetv-cli.go && upx ./dogetv-cli.exe

macOS

GOOS=darwin GOARCH=amd64 go build -ldflags '-w -s' -o dogetv-cli dogetv-cli.go && upx ./dogetv-cli

安装UPX

UPX: the Ultimate Packer for eXecutables
使用 Homebrew 安装
brew install upx

列表联动功能

import UIKit

class CategoryViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var collectionView: UICollectionView!
    
    var collectionViewIsScrollDown: Bool = false
    var collectionViewLastOffsetY: CGFloat = 0
    
    var categories: [String] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.tableFooterView = UIView()
        tableView.separatorColor = .white
        tableView.backgroundColor = .groupTableViewBackground
        tableView.showsVerticalScrollIndicator = false
        tableView.scrollsToTop = false
        
        tableView.register(CategoryTableViewCell.self, forCellReuseIdentifier: "cell")
        collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        collectionView.register(CollectionViewSectionHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "header")
        
        if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
            let width = (UIScreen.main.bounds.width - tableView.frame.width - 16) / 3
            let height = width + 10
            layout.itemSize = CGSize(width: width, height: height)
            layout.sectionInset = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
            layout.minimumLineSpacing = 2
            layout.minimumInteritemSpacing = 2
            layout.sectionHeadersPinToVisibleBounds = true
            layout.headerReferenceSize = CGSize(width: collectionView.frame.width, height: 44)
        }
        
        categories = (1...20).map{ "分类\($0)" }
        tableView.selectRow(at: IndexPath(row: 0, section: 0), animated: true, scrollPosition: .none)
    }
}

extension CategoryViewController: UITableViewDataSource, UITableViewDelegate {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return categories.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = categories[indexPath.row]
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        collectionViewScrollToTop(at: indexPath.row)
        tableView.scrollToRow(at: indexPath, at: .top, animated: true)
    }
}

extension CategoryViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return categories.count
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 15
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! CollectionViewCell
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "header", for: indexPath) as! CollectionViewSectionHeader
        header.titleLabel.text = categories[indexPath.section]
        return header
    }
    
    func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) {
        if !collectionViewIsScrollDown && (collectionView.isDragging || collectionView.isDecelerating) {
            tableView.selectRow(at: IndexPath(row: indexPath.section,  section: 0), animated: true, scrollPosition: .top)
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) {
        if collectionViewIsScrollDown && (collectionView.isDragging || collectionView.isDecelerating) {
            tableView.selectRow(at: IndexPath(row: indexPath.section + 1, section: 0), animated: true, scrollPosition: .top)
        }
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard scrollView == collectionView else { return }
        let offsetY = scrollView.contentOffset.y
        collectionViewIsScrollDown = collectionViewLastOffsetY < offsetY
        collectionViewLastOffsetY = offsetY
    }
    
    func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
        guard scrollView == collectionView, !categories.isEmpty else { return }
        tableView.selectRow(at: IndexPath(row: 0, section: 0), animated: false, scrollPosition: .none)
    }
}

extension CategoryViewController {
    func collectionViewScrollToTop(at section: Int, animated: Bool = true) {
        let headerRect = collectionViewHeaderFrame(at: section)
        let top = CGPoint(x: 0, y: headerRect.origin.y - collectionView.contentInset.top)
        collectionView.setContentOffset(top, animated: animated)
    }
    
    func collectionViewHeaderFrame(at section: Int) -> CGRect {
        let indexPath = IndexPath(item: 0, section: section)
        let attributes = collectionView.collectionViewLayout.layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, at: indexPath)
        guard let firstCellFrame = attributes?.frame else { return .zero }
        return firstCellFrame
    }
}




class CategoryTableViewCell: UITableViewCell {
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setup()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
    
    func setup() {
        textLabel?.font = .systemFont(ofSize: 12)
        textLabel?.textAlignment = .center
        backgroundColor = .groupTableViewBackground
    }
    
    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
        contentView.backgroundColor = selected ? .white : .groupTableViewBackground
        isHighlighted = selected
    }
}

class CollectionViewSectionHeader: UICollectionReusableView {
    
    lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 12)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
    
    func setup() {
        backgroundColor = .groupTableViewBackground
        addSubview(titleLabel)
        
        titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        titleLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 8).isActive = true
        titleLabel.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -8).isActive = true
    }
}

class CollectionViewCell: UICollectionViewCell {
    
    lazy var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.image = UIImage(named: "demo")
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()
    
    lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.text = "#Hello#"
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 12)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
    
    func setup() {
        addSubview(imageView)
        addSubview(titleLabel)
        
        imageView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
        imageView.leftAnchor.constraint(equalTo: contentView.leftAnchor).isActive = true
        imageView.rightAnchor.constraint(equalTo: contentView.rightAnchor).isActive = true
        
        titleLabel.topAnchor.constraint(greaterThanOrEqualTo: imageView.bottomAnchor, constant: 8).isActive = true
        titleLabel.leftAnchor.constraint(equalTo: contentView.leftAnchor).isActive = true
        titleLabel.rightAnchor.constraint(equalTo: contentView.rightAnchor).isActive = true
        titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8).isActive = true
        
    }
}

实现一个波浪动画效果(CADisplayLink定时器的使用样例)

import UIKit
import QuartzCore

// 波浪曲线动画视图
// 波浪曲线公式:y = h * sin(a * x + b); h: 波浪高度, a: 波浪宽度系数, b: 波浪的移动
class WaveView: UIView {
    // 波浪高度h
    var waveHeight: CGFloat = 7
    // 波浪宽度系数a
    var waveRate: CGFloat = 0.01
    // 波浪移动速度
    var waveSpeed: CGFloat = 0.05
    // 真实波浪颜色
    var realWaveColor: UIColor = .blue
    // 阴影波浪颜色
    var maskWaveColor: UIColor = UIColor.blue.withAlphaComponent(0.5)
    // 波浪位置(默认是在下方)
    var waveOnBottom = true

    // 定时器
    private var displayLink: CADisplayLink?
    // 真实波浪
    private var realWaveLayer: CAShapeLayer?
    // 阴影波浪
    private var maskWaveLayer: CAShapeLayer?
    // 波浪的偏移量
    private var offset: CGFloat = 0
    
    // 视图初始化
    override init(frame: CGRect) {
        super.init(frame: frame)
        initWaveParameters()
    }
    
    // 视图初始化
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        initWaveParameters()
    }
    
    // 组件初始化
    private func initWaveParameters() {
        // 真实波浪配置
        realWaveLayer = CAShapeLayer()
        var frame = bounds
        realWaveLayer?.frame.origin.y = frame.height - waveHeight
        frame.size.height = waveHeight
        realWaveLayer?.frame = frame
        
        // 阴影波浪配置
        maskWaveLayer = CAShapeLayer()
        maskWaveLayer?.frame.origin.y = frame.height - waveHeight
        frame.size.height = waveHeight
        maskWaveLayer?.frame = frame
        
        layer.addSublayer(maskWaveLayer!)
        layer.addSublayer(realWaveLayer!)
    }
    
    // 开始播放动画
    func startWave() {
        // 开启定时器
        displayLink = CADisplayLink(target: self, selector: #selector(wave))
        displayLink?.add(to: .current, forMode: .common)
    }
    
    // 停止播放动画
    func endWave() {
        // 结束定时器
        displayLink?.invalidate()
        displayLink = nil
    }
    
    // 定时器响应(每一帧都会调用一次)
    @objc func wave() {
        // 波浪移动的关键:按照指定的速度偏移
        offset += waveSpeed
        
        // 起点y坐标(没有波浪的一侧)
        let startY = waveOnBottom ? 0 : frame.height
        
        let realPath = UIBezierPath()
        realPath.move(to: CGPoint(x: 0, y: startY))
        
        let maskPath = UIBezierPath()
        maskPath.move(to: CGPoint(x: 0, y: startY))
        
        var x = CGFloat(0)
        var y = CGFloat(0)
        while x <= bounds.width {
            // 波浪曲线
            y = waveHeight * sin(x * waveRate + offset)
            // 如果是下波浪还要加上视图高度
            let realY = y + (self.waveOnBottom ? frame.height : 0)
            let maskY = -y + (self.waveOnBottom ? frame.height : 0)
            
            realPath.addLine(to: CGPoint(x: x, y: realY))
            maskPath.addLine(to: CGPoint(x: x, y: maskY))
            
            // 增量越小,曲线越平滑
            x += 0.1
        }
        
        // 回到起点对侧
        realPath.addLine(to: CGPoint(x: frame.width, y: startY))
        maskPath.addLine(to: CGPoint(x: frame.width, y: startY))
        
        // 闭合曲线
        maskPath.close()
        // 把封闭图形的路径赋值给CAShapeLayer
        maskWaveLayer?.path = maskPath.cgPath
        maskWaveLayer?.fillColor = maskWaveColor.cgColor
        
        realPath.close()
        realWaveLayer?.path = realPath.cgPath
        realWaveLayer?.fillColor = realWaveColor.cgColor
    }
}

Swift 泛型命名空间

import Foundation
import CommonCrypto


protocol Compatible { }

struct Wrapper<Base> {
    let base: Base
    init(_ base: Base) {
        self.base = base
    }
}


extension Compatible {
    var pp: Wrapper<Self> {
        get { return Wrapper(self) }
        set { }
    }
}


extension String: Compatible {}

extension Wrapper where Base == String {
    var md5: String {
        guard let data = base.data(using: .utf8) else {
            return base
        }
        var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
        _ = data.withUnsafeBytes { bytes in
            return CC_MD5(bytes, CC_LONG(data.count), &digest)
        }
        return digest.map { String(format: "%02x", $0) }.joined()
    }
}
// 调用时,使用 xxx.pp.xxx
"Hello".pp.md5 // "8b1a9953c4611296a827abf8c47804d7"

Debounce action in Flutter

import 'package:flutter/foundation.dart';
import 'dart:async';

class Debouncer {
  final int milliseconds;
  VoidCallback action;
  Timer _timer;

  Debouncer({ this.milliseconds });

  run(VoidCallback action) {
    if (_timer != null) {
      _timer.cancel();
    }

    _timer = Timer(Duration(milliseconds: milliseconds), action);
  }
}

trigger

final _debouncer = Debouncer(milliseconds: 500);

onTextChange(String text) {
  _debouncer.run(() => print(text));
}

SwiftCLI

Package.swift

import PackageDescription

let package = Package(
    name: "dogetv-cli",
    dependencies: [
        .package(url: "https://github.com/jakeheis/SwiftCLI", from: "5.2.2"),
        .package(url: "https://github.com/onevcat/Rainbow", from: "3.0.0")
    ],
    targets: [
        .target(
            name: "dogetv-cli",
            dependencies: ["SwiftCLI", "Rainbow"]),
    ]
)

main.swift

#if os(OSX)
import Darwin
#else
import Glibc
#endif
import Foundation
import SwiftCLI
import Rainbow

let HOST = "https://tv.popeye.vip"

func makeRequest<T: Decodable> (url: URL, type: T.Type) -> T? {
    let sema: DispatchSemaphore = DispatchSemaphore(value: 0)
    var result: T? = nil
    let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
        if let data = data {
            result = try? JSONDecoder().decode(T.self, from: data)
        }
        sema.signal()
    }
    task.resume()
    sema.wait()
    return result
}

func getBody(from url: URL) -> String? {
    let sema: DispatchSemaphore = DispatchSemaphore(value: 0)
    var result: String? = nil
    let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
        if let data = data {
            result = String.init(data: data, encoding: .utf8)
        }
        sema.signal()
    }
    task.resume()
    sema.wait()
    return result
}

class SearchCommand: Command {
    let name: String = "search"
    let shortDescription: String = "搜索电影/电视剧/综艺/影人"
    let keyword = Parameter()
    
    func execute() throws {
        let url = URL(string: "\(HOST)/pumpkin/search/\(keyword.value.encodedURI)")!;
        let result = makeRequest(url: url, type: Response<[Video]>.self)
        
        guard let videos = result?.data, !videos.isEmpty else {
            stderr <<< "未找到相关资源".yellow
            return
        }
        
        let output = videos.enumerated().reduce("") { (result, item) -> String in
            return result  + "\n[\(String(item.offset).blue.underline)] \(item.element.name)"
        }
        
        stdout <<< output
        
        let msg = "区间 0 ~ \(videos.count - 1)".red
        let index = Input.readInt(prompt: "Input index:".lightCyan, validation: [.greaterThan(-1, message: msg), .lessThan(videos.count, message: msg)])
        let video = videos[index]
        
        if !video.cover.contains("vcinema") {
            _ = cli.go(with: ["blueray-episodes", video.id])
            return
        }
        
        _ = cli.go(with: ["detail", video.id])
    }
}

class VideoDetailCommand: Command {
    let name: String = "detail"
    let shortDescription: String = "视频信息"
    let id = Parameter()

    func execute() throws {
        
        let url = URL(string: "\(HOST)/pumpkin/video/\(id.value)")!
        let result = makeRequest(url: url, type: Response<VideoDetail>.self)
        guard let videoDetail = result?.data else {
            stderr <<< "未找到相关资源".yellow
            return
        }
        
        guard let seasons = videoDetail.seasons, !seasons.isEmpty else{
            _ = cli.go(with: ["episodes", id.value])
            return
        }

        let output = seasons.enumerated().reduce("") { (result, item) -> String in
            return result + (item.offset % 4 == 0 ? "\n" : "") + "[\(String(item.offset).blue.underline)] \(item.element.name)\t"
        }

        stdout <<< output
        
        let msg = "有效区间 0 ~ \(seasons.count - 1)".red
        let index = Input.readInt(prompt: "Input index:".lightCyan, validation: [.greaterThan(-1, message: msg), .lessThan(seasons.count, message: msg)])
        let seasonId = seasons[index].id
        _ = cli.go(with: ["season-episodes", id.value, seasonId])
    }
}


class VideoEpisodesCommand: Command {
    let name: String = "episodes"
    let shortDescription: String = "播放列表"
    let id = Parameter()
    
    func execute() throws {
        let url = URL(string: "\(HOST)/pumpkin/video/\(id.value)/stream")!
        let result = makeRequest(url: url, type: Response<[Episode]>.self)
        
        guard let episodes = result?.data, !episodes.isEmpty else {
            stderr <<< "未找到相关资源".yellow
            return
        }
        
        let output = episodes.enumerated().reduce("") { (result, item) -> String in
            return result + (item.offset % 4 == 0 ? "\n" : "") + "[\(String(item.offset).blue.underline)] \(item.element.title)\t"
        }

        stdout <<< output

        let msg = "有效区间 0 ~ \(episodes.count - 1)".red
        let index = Input.readInt(prompt: "Input index:".lightCyan, validation: [.greaterThan(-1, message: msg), .lessThan(episodes.count, message: msg)])
        
        let streamUrl = episodes[index].url
        
        do {
            try run("open", "iina://weblink?url=\(streamUrl.encodedURI)")
        } catch {
            stderr <<< error.localizedDescription.red
        }
        
    }
}

class SeasonEpisodesCommand: Command {
    let name: String = "season-episodes"
    let shortDescription: String = "分季播放列表"
    let id = Parameter()
    let seasonId = Parameter()
    
    func execute() throws {
        let url = URL(string: "\(HOST)/pumpkin/video/\(id.value)?sid=\(seasonId.value)")!
        let result = makeRequest(url: url, type: Response<VideoDetail>.self)
        
        guard let videoDetail = result?.data, let seasons = videoDetail.seasons, !seasons.isEmpty else {
            stderr <<< "未找到相关资源".yellow
            return
        }
        
        let season = seasons.first { $0.episodes?.isEmpty == false }
        guard let episodes = season?.episodes, !episodes.isEmpty else {
            stderr <<< "未找到相关资源".yellow
            return
        }
        
        let output = episodes.enumerated().reduce("") { (result, item) -> String in
            return result + (item.offset % 4 == 0 ? "\n" : "") + "[\(String(item.offset).blue.underline)] \(item.element.title)\t"
        }
        
        stdout <<< output

        let msg = "有效区间 0 ~ \(episodes.count - 1)".red
        let index = Input.readInt(prompt: "Input index:".lightCyan.underline, validation: [.greaterThan(-1, message: msg), .lessThan(episodes.count, message: msg)])

        guard let episodeId = episodes[index].id else {
            stderr <<< "未找到相关资源".yellow
            return
        }
        _ = cli.go(with: ["episodes", episodeId])
    }
}

class BluerayEpisodesCommand: Command {
    let name: String = "blueray-episodes"
    let shortDescription: String = "高清资源播放列表"
    let id = Parameter()
    
    func execute() throws {
        let url = URL(string: "\(HOST)/4k/detail/\(id.value)/episodes")!
        let result = makeRequest(url: url, type: Response<[Episode]>.self)
        
        guard let episodes = result?.data, !episodes.isEmpty else {
            stderr <<< "未找到相关资源".yellow
            return
        }
        
        let output = episodes.enumerated().reduce("") { (result, item) -> String in
            return result + (item.offset % 4 == 0 ? "\n" : "") + "[\(String(item.offset).blue.underline)] \(item.element.title)\t"
        }
        
        stdout <<< output
        
        let msg = "有效区间 0 ~ \(episodes.count - 1)".red
        let index = Input.readInt(prompt: "Input index:".lightCyan, validation: [.greaterThan(-1, message: msg), .lessThan(episodes.count, message: msg)])
        
        guard let episodeUrl = URL(string: episodes[index].url) else {
            stderr <<< "未找到相关资源".yellow
            return
        }

        guard let body = getBody(from: episodeUrl) else { return }
        guard let regex = try? NSRegularExpression(pattern: "(http:|https:)//(.*).m3u8") else { return }
        guard let matched = regex.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)) else {
            return
        }
        
        let m3u8 = String(body[Range(matched.range, in: body)!])
        
        do {
            try run("open", "iina://weblink?url=\(m3u8.encodedURI)")
        } catch {
            stderr <<< error.localizedDescription.yellow
        }

    }
}


let cli = CLI(name: "dogetv-cli", version: "1.0.0", description: "搜索电影/电视剧/综艺/影人", commands: [SearchCommand(), VideoDetailCommand(), VideoEpisodesCommand(), SeasonEpisodesCommand(), BluerayEpisodesCommand()])
_ = cli.go()

extensions.swift

#if os(OSX)
    import Darwin
#else
    import Glibc
#endif
import Foundation

extension String {
    public var encodedURI: String {
        var characterSet = CharacterSet.alphanumerics
        characterSet.insert(charactersIn: "-_.!~*'()")
        return self.addingPercentEncoding(withAllowedCharacters: characterSet) ?? self
    }
}

models.swift

#if os(OSX)
    import Darwin
#else
    import Glibc
#endif
import Foundation

public struct Response<T: Decodable>: Decodable {
    public let code: Int
    public let msg: String
    public let data: T
}

public struct IPTV: Decodable {
    public let id: String
    public let category: String
}

public struct Video: Decodable {
    public let id: String
    public let name: String
    public let cover: String
}

public struct Season: Decodable {
    public let id: String
    public let name: String
    public let episodes: [Episode]?
}

public struct Episode: Decodable {
    public let id: String?
    public let url: String
    public let title: String
}


public struct VideoDetail: Decodable {
    public let info: Video
    public let seasons: [Season]?
}

build

$swift build -c release

dogetv-cli

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"strings"

	"github.com/urfave/cli"
)

const (
	API_HOST = "https://tv.popeye.vip"
)

type Video struct {
	Name  string `json:"name"`
	ID    string `json:"id"`
	Area  string `json:"area"`
	Year  string `json:"year"`
	Cover string `json:"cover"`
}

func main() {
	app := cli.NewApp()
	app.Name = "dogetv-cli"
	app.Usage = "搜索影视资源"
	app.Version = "1.0.0"
	app.Author = "Popeye Lau"
	app.Email = "[email protected]"
	app.UsageText = "dogetv-cli search -k 权力的游戏"

	app.Commands = []cli.Command{
		{
			Name:      "search",
			Usage:     "按关键字搜索电影/演员/导演",
			UsageText: "dogetv-cli search -k 权力的游戏",
			Flags: []cli.Flag{
				cli.StringFlag{
					Name:  "keyword, k",
					Usage: "关键字",
				},
			},
			Action:      searchAction,
			Description: "搜索",
		},
	}
	app.Run(os.Args)
}

func searchAction(c *cli.Context) error {
	keyword := strings.TrimSpace(c.String("keyword"))
	if len(keyword) == 0 {
		keyword = readStringFromStdIn()
	}

	api := fmt.Sprintf("%v/pumpkin/search/%v", API_HOST, keyword)

	var resp struct {
		Data []Video `json:"data"`
	}

	if ok, err := readJSONFromURL(api, http.MethodGet, nil, &resp); !ok {
		fmt.Fprintf(c.App.Writer, "\n\033[1;31m%s\033[0m", err.Error())
		return nil
	}

	if len(resp.Data) == 0 {
		fmt.Fprintf(c.App.Writer, "\n\033[1;31m%s\033[0m", "未找到匹配资源")
		return nil
	}

	for index, item := range resp.Data {
		fmt.Fprintf(c.App.Writer, "\n\033[1;36m[%d]\033[0m %s", index, item.Name)
	}

	index := readIntFromStdIn(len(resp.Data))
	return fetchVideoInfo(resp.Data[index], c)
}

func fetchVideoInfo(video Video, c *cli.Context) error {
	id := video.ID
	api := fmt.Sprintf("%v/pumpkin/video/%v", API_HOST, id)

	if !strings.Contains(video.Cover, "vcinema") {
		return fetchEpisodes(fmt.Sprintf("%v/4k/detail/%v/episodes", API_HOST, id), c)
	}

	var resp struct {
		Data struct {
			Info    Video `json:"info"`
			Seasons []struct {
				ID   string `json:"id"`
				Name string `json:"name"`
			} `json:"seasons"`
		} `json:"data"`
	}
	if ok, err := readJSONFromURL(api, http.MethodGet, nil, &resp); !ok {
		fmt.Fprintf(c.App.Writer, "\n\033[1;31m%s\033[0m", err.Error())
		return err
	}

	if len(resp.Data.Seasons) == 0 {
		return fetchEpisodes(fmt.Sprintf("%v/pumpkin/video/%v/stream", API_HOST, id), c)
	}

	fmt.Fprint(c.App.Writer, "\n")
	for i, v := range resp.Data.Seasons {
		fmt.Fprintf(c.App.Writer, "\033[1;36m[%d]\033[0m %s\t", i, v.Name)
	}

	index := readIntFromStdIn(len(resp.Data.Seasons))
	sid := resp.Data.Seasons[index].ID
	return fetchSeasonEpisode(id, sid, c)
}

func fetchEpisodes(api string, c *cli.Context) error {
	var resp struct {
		Data []struct {
			URL   string `json:"url"`
			Title string `json:"title"`
		} `json:"data"`
	}

	if ok, err := readJSONFromURL(api, http.MethodGet, nil, &resp); !ok {
		fmt.Fprintf(c.App.Writer, "\n\033[1;31m%s\033[0m", err.Error())
		return err
	}

	if len(resp.Data) == 1 {
		openIINA(resp.Data[0].URL)
		return nil
	}

	fmt.Fprint(c.App.Writer, "\n")
	for i, v := range resp.Data {
		fmt.Fprintf(c.App.Writer, "\033[1;36m[%d]\033[0m %s\t", i, v.Title)
	}

	index := readIntFromStdIn(len(resp.Data))
	openIINA(resp.Data[index].URL)
	return nil
}

func fetchSeasonEpisode(id, sid string, c *cli.Context) error {
	api := fmt.Sprintf("%v/pumpkin/video/%v?sid=%v", API_HOST, id, sid)
	var resp struct {
		Data struct {
			Seasons []struct {
				ID       string `json:"id"`
				Name     string `json:"name"`
				Episodes []struct {
					ID    string `json:"id"`
					Title string `json:"title"`
				} `json:"episodes"`
			} `json:"seasons"`
		} `json:"data"`
	}

	if ok, err := readJSONFromURL(api, http.MethodGet, nil, &resp); !ok {
		fmt.Fprintf(c.App.Writer, "\n\033[1;31m%s\033[0m", err.Error())
		return err
	}

	var seasonIndex int
	for i, item := range resp.Data.Seasons {
		if len(item.Episodes) > 0 {
			seasonIndex = i
			fmt.Fprint(c.App.Writer, "\n")
			for index, episode := range item.Episodes {
				fmt.Fprintf(c.App.Writer, "\033[1;36m[%d]\033[0m %s\t", index, episode.Title)
			}
			break
		}
	}

	episodes := resp.Data.Seasons[seasonIndex].Episodes
	index := readIntFromStdIn(len(episodes))
	return fetchEpisodes(fmt.Sprintf("%v/pumpkin/video/%v/stream", API_HOST, episodes[index].ID), c)

}

func openIINA(episodeURL string) {
	episodeURL, err := getStreamURL(episodeURL)
	if err != nil {
		fmt.Printf("\n\033[1;31m%s\033[0m", err.Error())
		return
	}

	fmt.Println(episodeURL)
	encodeURL := url.QueryEscape(episodeURL)
	iina := fmt.Sprintf("iina://weblink?url=%s", encodeURL)
	openBrowser(iina)
}

func getStreamURL(source string) (string, error) {
	ext := filepath.Ext(source)
	if !strings.HasPrefix(ext, ".m3u8") && !strings.HasPrefix(ext, ".mp4") {
		url, err := fetchStreamURL(source)
		if err != nil {
			return "", err
		}
		return url, nil
	}
	return source, nil
}

func readJSONFromURL(url string, method string, params url.Values, target interface{}) (bool, error) {
	client := &http.Client{}
	req, err := http.NewRequest(method, url, strings.NewReader(params.Encode()))

	if err != nil {
		return false, err
	}

	resp, err := client.Do(req)
	if err != nil {
		return false, err
	}

	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	body = bytes.Trim(body, " ();")

	if err != nil {
		return false, err
	}

	err = json.Unmarshal(body, target)
	if err != nil {
		return false, err
	}

	return true, nil
}

func fetchStreamURL(source string) (string, error) {
	url, err := url.Parse(source)
	if err != nil {
		return "", err
	}

	client := &http.Client{}

	req, err := http.NewRequest("GET", url.String(), nil)
	if err != nil {
		return "", err
	}

	req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 12_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.1 Mobile/15E148 Safari/604.1")

	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	html := string(body)
	rp := regexp.MustCompile(`(http:|https:)\/\/(.*)\.m3u8`)
	result := rp.FindString(html)
	if len(result) == 0 {
		return "", errors.New("获取失败")
	}

	return result, nil
}

func openBrowser(url string) {
	var err error

	switch runtime.GOOS {
	case "linux":
		err = exec.Command("xdg-open", url).Start()
	case "windows":
		err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
	case "darwin":
		err = exec.Command("open", url).Start()
	default:
		err = fmt.Errorf("unsupported platform")
	}
	if err != nil {
		log.Fatal(err)
	}

}

func readIntFromStdIn(max int) int {
	index := -1
	fmt.Print("\n\033[1;33m序号> \033[0m")
	for index < 0 || index >= max {
		fmt.Scan(&index)
	}
	return index
}

func readStringFromStdIn() string {
	var input string
	fmt.Print("\n\033[1;33m关键字> \033[0m")
	for len(strings.TrimSpace(input)) == 0 {
		fmt.Scan(&input)
	}
	return input
}

A simple, fast, and fun package for building command line apps in Go

Flutter TextStyle Extensions

import 'dart:ui';
import 'package:flutter/material.dart';

extension TextStyleExtensions on TextStyle {
  TextStyle clr(Color v) => copyWith(color: v);

  TextStyle bgClr(Color v) => copyWith(backgroundColor: v);

  TextStyle size(double v) => copyWith(fontSize: v);

  TextStyle w(FontWeight v) => copyWith(fontWeight: v);

  TextStyle get light => w(FontWeight.w200);

  TextStyle get regular => w(FontWeight.normal);

  TextStyle get semiBold => w(FontWeight.w500);

  TextStyle get bold => w(FontWeight.bold);

  TextStyle fs(FontStyle v) => copyWith(fontStyle: v);

  TextStyle get italic => fs(FontStyle.italic);

  TextStyle get normal => fs(FontStyle.normal);

  TextStyle ltrSpace(double v) => copyWith(letterSpacing: v);

  TextStyle wrdSpace(double v) => copyWith(wordSpacing: v);

  TextStyle bsl(TextBaseline v) => copyWith(textBaseline: v);

  TextStyle lnHeight(double v) => copyWith(height: v);

  TextStyle loc(Locale v) => copyWith(locale: v);

  TextStyle fg(Paint v) => copyWith(foreground: v);

  TextStyle bg(Paint v) => copyWith(background: v);

  TextStyle shdwList(List<Shadow> v) => copyWith(shadows: v);

  TextStyle ftList(List<FontFeature> v) => copyWith(fontFeatures: v);

  TextStyle dec(TextDecoration v, {Color color, TextDecorationStyle style, double thickness}) =>
      copyWith(decoration: v, decorationColor: color, decorationStyle: style, decorationThickness: thickness);

  TextStyle decClr(Color v) => copyWith(decorationColor: v);

  TextStyle decStyle(TextDecorationStyle v) => copyWith(decorationStyle: v);

  TextStyle decThick(double v) => copyWith(decorationThickness: v);
}

Swift String 高度计算

import UIKit

    func heightOfString(withConstrainedWidth width: CGFloat,
                        attributes: [NSAttributedString.Key: Any],
                        insets: UIEdgeInsets = .zero) -> CGFloat {
        
        let constraintRect = CGSize(width: width - insets.left - insets.right, height: .greatestFiniteMagnitude)
        
        let boundingBox = self.boundingRect(with: constraintRect,
                                            options: [.usesLineFragmentOrigin, .usesFontLeading],
                                            attributes: attributes, context: nil)
        
        return ceil(boundingBox.height) + insets.top + insets.bottom
    }

    var trimed: String {
        return trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
    }
}
//使用
let str = "注意该函数和 ReadAtLeast 的区别:ReadFull 将 buf 读满;而 ReadAtLeast 是最少读取 min 个字节。"
let insets = UIEdgeInsets(top: 20, left: 8, bottom: 20, right: 8)
//行间距
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 5
//样式
let attributes:[NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .caption2), .paragraphStyle: paragraphStyle]
//计算高度
let textHeight = data.desc.trimed.heightOfString(withConstrainedWidth: bounds.width, attributes: attributes, insets: insets)

UIFont preferredFont Sizes

Style Font Size
.largeTitle SFUIDisplay 34.0
.title1 SFUIDisplay
(-Light on iOS <=10)
28.0
.title2 SFUIDisplay 22.0
.title3 SFUIDisplay 20.0
.headline SFUIText-Semibold 17.0
.callout SFUIText 16.0
.subheadline SFUIText 15.0
.body SFUIText 17.0
.footnote SFUIText 13.0
.caption1 SFUIText 12.0
.caption2 SFUIText 11.0

iOS Font Sizes

Custom Popover Menu

//
//  SelectionViewController.swift
//  dogeTV
//
//  Created by Popeye Lau on 2019/3/28.
//  Copyright © 2019 Popeye Lau. All rights reserved.
//
import UIKit

class PopoverPresentation: NSObject, UIPopoverPresentationControllerDelegate {
    
    static let sharedInstance = PopoverPresentation()
    
    override init() {
        super.init()
    }
    
    func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
        return .none
    }
    
    static func configurePresentation(forController controller : UIViewController) -> UIPopoverPresentationController {
        controller.modalPresentationStyle = .popover
        let presentationController = controller.presentationController as! UIPopoverPresentationController
        presentationController.backgroundColor = .white
        presentationController.delegate = PopoverPresentation.sharedInstance
        return presentationController
    }
    
}


class SelectionViewController<Element> : UITableViewController {
    
    typealias SelectionHandler = (Element) -> Void
    typealias LabelProvider = (Element) -> String
    
    private let values : [Element]
    private let labels : LabelProvider
    private let onSelect : SelectionHandler?
    
    init(_ values : [Element], labels : @escaping LabelProvider = String.init(describing:), onSelect : SelectionHandler? = nil) {
        self.values = values
        self.onSelect = onSelect
        self.labels = labels
        super.init(style: .plain)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.separatorColor = .groupTableViewBackground
        tableView.showsVerticalScrollIndicator = false
        tableView.separatorInset = .zero
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return values.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
        cell.textLabel?.text = labels(values[indexPath.row])
        cell.textLabel?.textColor = .darkGray
        cell.textLabel?.font = .systemFont(ofSize: 13)
        cell.imageView?.image = UIImage(named: "line")
        cell.imageView?.tintColor = .darkGray
        cell.textLabel?.textAlignment = .center
        return cell
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        self.dismiss(animated: true)
        onSelect?(values[indexPath.row])
    }
    
}
  @objc func switchResource(_ sender: UIButton) {
        guard let video = media, video.source > 0 else {
            showInfo("没有其它路线可用")
            return
        }
        
        if selectionController == nil {
            let sources = Array(0..<min(video.source, 8))
            selectionController = SelectionViewController(sources, labels: { "线路 \($0+1)" } ){ [weak self] (source) in
                self?.resourceIndex = source
                self?.refreshResource(with: source)
            }
            selectionController?.preferredContentSize = CGSize(width: 130, height: sources.count * 44)
        }
        showSourceSelectionView(with: sender)
    }
    
    func showSourceSelectionView(with sourceView: UIView) {
        guard let controller = selectionController else {
            return
        }
        let presentationController = PopoverPresentation.configurePresentation(forController: controller)
        presentationController.sourceView = sourceView
        presentationController.sourceRect = sourceView.bounds.insetBy(dx: -10, dy: 0)
        presentationController.permittedArrowDirections = [.left]
        present(controller, animated: true)
    }

Flutter iOS 插件相关

xs_tongdun_plugin.podspec
xs_tongdun_plugin.podspec

xs_payment_plugin.podspec
xs_payment_plugin.podspec

  • name:包名
  • version:当前版本(注意,是当前版本,假如你后续更新了新版本,需要修改此处)
  • summary:简要描述,在pod search ZCPKit的时候会显示该信息。
  • description:详细描述
  • homepage:页面链接
  • license:开源协议
  • author:作者
  • source:源码git地址
  • platform:平台 & 版本号
  • source_files:源文件(可以包含.h和.m)
  • public_header_files:头文件(.h文件)
  • resources:资源文件(配置的文件都会被放到mainBundle中)
  • resource_bundles:资源文件(配置的文件会放到你自己指定的bundle中)
  • frameworks:依赖的系统框架
  • vendored_frameworks:依赖的非系统框架
  • libraries:依赖的系统库,需要去除前缀lib,如静态库依赖是libz.tbd,则s.libraries = 'z'
  • vendored_libraries:依赖的非系统的静态库
  • dependency:依赖的三方库

Specs and the Specs Repo
podspec 文件基础模块说明

Flutter Tricks: Widget Size & Position

import 'package:flutter/material.dart';
 
class ContextUtils {
  // Takes a key, and in 1 frame, returns the size of the context attached to the key
  static void getFutureSizeFromGlobalKey(GlobalKey key, Function(Size size) callback) {
    Future.microtask(() {
      Size size = getSizeFromContext(key.currentContext);
      if (size != null) {
        callback(size);
      }
    });
  }
 
  // Shortcut to get the renderBox size from a context
  static Size getSizeFromContext(BuildContext context) {
    RenderBox rb = context.findRenderObject() as RenderBox;
    return rb?.size ?? Size.zero;
  }
 
  // Shortcut to get the global position of a context
  static Offset getOffsetFromContext(BuildContext context, [Offset offset = null]) {
    RenderBox rb = context.findRenderObject() as RenderBox;
    return rb?.localToGlobal(offset ?? Offset.zero);
  }
}

Flutter Tricks: Widget Size & Position

Flutter iOS 启动图适配

  • 新建 Splash.imageset
  • 修改 Splash.imageset/Contents.json 内容如下
{
  "images": [
    {
      "idiom": "iphone",
      "scale": "1x"
    },
    {
      "idiom": "iphone",
      "filename": "640*960.png",
      "scale": "2x"
    },
    {
      "idiom": "iphone",
      "filename": "1125*2436.png",
      "scale": "3x"
    },
    {
      "idiom": "iphone",
      "subtype": "retina4",
      "scale": "1x"
    },
    {
      "idiom": "iphone",
      "filename": "640*1136.png",
      "subtype": "retina4",
      "scale": "2x"
    },
    {
      "idiom": "iphone",
      "subtype": "retina4",
      "scale": "3x"
    },
    {
      "idiom": "iphone",
      "filename": "1242*2208.png",
      "subtype": "736h",
      "scale": "3x"
    },
    {
      "idiom": "iphone",
      "filename": "750*1334.png",
      "subtype": "667h",
      "scale": "2x"
    },
    {
      "idiom": "iphone",
      "filename": "1125*2436.png",
      "subtype": "2436h",
      "scale": "3x"
    },
    {
      "idiom": "iphone",
      "filename": "1242*2688.png",
      "subtype": "2688h",
      "scale": "3x"
    },
    {
      "idiom": "iphone",
      "filename": "828*1792.png",
      "subtype": "1792h",
      "scale": "2x"
    }
  ],
  "info": {
    "version": 1,
    "author": "xcode"
  }
}
  • LaunchScreen.storyboard 添加 UIImageView 设好约束,图片选择 Splash 即可

Flutter 中读取 iOS Assets.xcassets 图片资源
iOS 读取 Flutter assets 图片资源
安装官方插件 ios_platform_images

PopupMenuButton

2021-01-14 11 50 21

import 'package:flutter/material.dart';

class PopupMenuPage extends StatefulWidget {
  @override
  _PopupMenuPageState createState() => _PopupMenuPageState();
}

class _PopupMenuPageState extends State<PopupMenuPage> {
  static const List<String> menus = ["hello", "world"];

  bool isOpen;
  String selected;

  final GlobalKey<PopupMenuButtonState<String>> key = GlobalKey();

  @override
  void initState() {
    super.initState();
    isOpen = false;
    selected = menus.last;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black87,
      body: Center(
        child: PopupMenuButton<String>(
          key: key,
          padding: EdgeInsets.zero,
          itemBuilder: (context) => menus.map((e) {
            final color = selected == e ? Colors.orange : Colors.black;
            return PopupMenuItem(
              value: e,
              child: Container(
                width: 120,
                child: Row(
                  children: [
                    Icon(Icons.keyboard_arrow_right, color: color),
                    SizedBox(width: 6),
                    Text(e, style: TextStyle(color: color))
                  ],
                ),
              ),
            );
          }).toList(),
          child: GestureDetector(
            onTap: () {
              key.currentState.showButtonMenu();
              setState(() {
                isOpen = !isOpen;
              });
            },
            child: Container(
              decoration: BoxDecoration(
                  borderRadius: isOpen
                      ? BorderRadius.only(
                          topLeft: Radius.circular(8),
                          topRight: Radius.circular(8))
                      : BorderRadius.circular(8),
                  color: Colors.white),
              alignment: Alignment.center,
              width: 100,
              height: 40,
              child: Text("Open", style: TextStyle(color: Colors.black)),
            ),
          ),
          offset: Offset(20, 39),
          elevation: 0,
          onSelected: (e) {
            closePopup(value: e);
          },
          onCanceled: closePopup,
          shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.only(
                  topLeft: Radius.circular(8),
                  bottomLeft: Radius.circular(8),
                  bottomRight: Radius.circular(8))),
        ),
      ),
    );
  }

  closePopup({String value}) {
    Future.delayed(Duration(milliseconds: 300), () {
      setState(() {
        isOpen = false;
        selected = value ?? selected;
      });
    });
  }
}

Handle Auto Layout with different screen sizes

class Device {
    // 以 iPhone 6 尺寸为设计稿
    static let base: CGFloat = 375

    static var ratio: CGFloat {
        return UIScreen.main.bounds.width / base
    }
}

xtension CGFloat {

    var adjusted: CGFloat {
        return self * Device.ratio
    }
}

extension Double {

    var adjusted: CGFloat {
        return CGFloat(self) * Device.ratio
    }
}

extension Int {
    var adjusted: CGFloat {
        return CGFloat(self) * Device.ratio
    }
}
label.font = UIFont.systemFont(ofSize: 23.adjusted)

phoneTextField.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 30.adjusted)
phoneTextField.rightAnchor.constraint(equalTo: container.rightAnchor, constant: -30.adjusted)

imageView.widthAnchor.constraint(equalToConstant: 80.adjusted)
imageView.heightAnchor.constraint(equalToConstant: 90.adjusted)

How to handle Auto Layout with different screen sizes

Flutter 一些坑

WebView 相关

if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
       webView.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}
webView.getSettings().setBlockNetworkImage(false);
  • iOS 允许混合模式 (info.plist)
<key>NSAppTransportSecurity</key>
 <dict>
   <key>NSAllowsArbitraryLoads</key>
   <true/>
  <key>NSAllowsArbitraryLoadsInWebContent</key>
  <true/>
</dict>

UITableView Section 圆角显示

import UIKit


class SectionHeader: UITableViewHeaderFooterView {
    
    lazy var shapeLayer: CAShapeLayer = CAShapeLayer()
    let cornerRadius: CGFloat = 10
    let inset: CGFloat = 10
    
    override init(reuseIdentifier: String?) {
        super.init(reuseIdentifier: reuseIdentifier)
        setup()
    }

    func setup() {
        backgroundView = UIView(frame: bounds)
        backgroundView?.backgroundColor = .black
        layer.mask = shapeLayer
    }
    
    override var frame: CGRect {
        get { return super.frame }
        set {
            var frame = newValue
            frame.origin.x += inset
            frame.origin.y += inset
            frame.size.width -= inset * 2
            frame.size.height -= inset
            super.frame = frame
        }
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        let bezierPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
        shapeLayer.path = bezierPath.cgPath
    }
}

class TableViewCell: UITableViewCell {
    
    let inset: CGFloat = 10
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        backgroundColor = .lightGray
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override var frame: CGRect {
        get { return super.frame }
        set {
            var frame = newValue
            frame.origin.x += inset
            frame.size.width -= inset * 2
            super.frame = frame
        }
    }
}

class ViewController: UIViewController {
    
    lazy var tableView: UITableView = {
        let tableView = UITableView(frame: view.bounds, style: .grouped)
        tableView.register(TableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.register(SectionHeader.self, forHeaderFooterViewReuseIdentifier: "header")
        tableView.tableFooterView = UIView()
        tableView.dataSource = self
        tableView.delegate = self
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(tableView)
        tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        tableView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        tableView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
    }
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 3
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 5
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = "\(indexPath.section) - \(indexPath.row)"
        cell.accessoryType = .disclosureIndicator
        return cell
    }
    
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        let numberOfRowInSection =  self.tableView(tableView, numberOfRowsInSection: indexPath.section)
        guard indexPath.row == numberOfRowInSection - 1 else {
            cell.layer.mask = nil
            return
        }
        
        let shapeLayer = CAShapeLayer()
        var bounds = cell.bounds
        bounds.size.height -= 1
        let bezierPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.bottomLeft, .bottomRight], cornerRadii: CGSize(width: 10, height: 10))
        shapeLayer.path = bezierPath.cgPath
        cell.layer.mask = shapeLayer
    }
    
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: "header") else {
            return nil
        }
        return header
    }
    
    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        return 60
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 40
    }
}

Flutter 表单处理

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class LoginForm {
  String phone;
  String password;
  LoginForm({this.phone = "", this.password = ""});

  @override
  String toString() => '{"phone": "$phone", "password": "$password"}';
}

@immutable
class FormPage extends StatefulWidget {
  const FormPage({Key key}) : super(key: key);
  @override
  State<FormPage> createState() => _FormPageState();
}

class _FormPageState extends State<FormPage> {
  ///表单验证状态(控制登录按钮状态)
  final ValueNotifier<bool> _allFilled = ValueNotifier(false);

  ///表单数据
  LoginForm _loginForm;

  ///用户名
  FieldStatus<String, LoginForm> _phoneField;

  ///密码
  FieldStatus<String, LoginForm> _pwdField;

  ///表单项
  Iterable<FieldStatus> _fields;

  ///按钮焦点
  final FocusNode _buttonFocusNode = FocusNode();

  @override
  void initState() {
    super.initState();

    _loginForm = LoginForm();

    _phoneField = FieldStatus<String, LoginForm>(
      formData: _loginForm,
      value: (data) => data.phone,
      onChanged: (data, phone) => data.phone = phone,
      validator: (data, phone) => phone?.isEmpty ?? true ? '手机号不能为空' : null,
    );

    _pwdField = FieldStatus<String, LoginForm>(
      formData: _loginForm, 
      value: (data) => data.password,
      onChanged: (data, pwd) => data.password = pwd,
      validator: (data, pwd) => pwd?.isEmpty ?? true ? '密码不能为空' : null,
    );

    _fields = <FieldStatus>[_phoneField, _pwdField];
    _setValidationStatus();
  }

  @override
  void dispose() {
    _allFilled.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Login")),
      body: SafeArea(
        child: Center(
          child: Card(
            margin: EdgeInsets.all(16),
            elevation: 4,
            clipBehavior: Clip.antiAlias,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.all(
                Radius.circular(8),
              ),
            ),
            child: Padding(padding: EdgeInsets.all(18), child: form),
          ),
        ),
      ),
    );
  }

  Widget get form => Form(
        autovalidateMode: AutovalidateMode.onUserInteraction,
        onChanged: _setValidationStatus,
        child: Builder(
          builder: (context) => SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                TextFormField(
                  autofocus: true,
                  focusNode: _phoneField.focusNode,
                  onEditingComplete: _nextEmpty,
                  initialValue: _loginForm.phone,
                  onSaved: _phoneField.onChanged,
                  onChanged: _phoneField.onChanged,
                  validator: _phoneField.validator,
                  keyboardType: TextInputType.phone,
                  inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                  decoration: const InputDecoration(labelText: '手机号'),
                ),
                const SizedBox(height: 10),
                TextFormField(
                  focusNode: _pwdField.focusNode,
                  onEditingComplete: _nextEmpty,
                  initialValue: _loginForm.password,
                  onSaved: _pwdField.onChanged,
                  onChanged: _pwdField.onChanged,
                  validator: _pwdField.validator,
                  keyboardType: TextInputType.text,
                  obscureText: true,
                  decoration: const InputDecoration(labelText: '密码'),
                ),
                const SizedBox(height: 10),
                ValueListenableBuilder<bool>(
                  valueListenable: _allFilled,
                  builder: (context, value, _) => RaisedButton(
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.all(
                          Radius.circular(8),
                        ),
                      ),
                      focusNode: _buttonFocusNode,
                      color: Theme.of(context).primaryColor,
                      textColor: Theme.of(context).buttonColor,
                      onPressed: value ? () => _submit(context) : null,
                      child: const Text('登录')),
                ),
              ],
            ),
          ),
        ),
      );

  void _nextEmpty() {
    final fieldNode = _fields
        .firstWhere((field) => !field.filled, orElse: () => null)
        ?.focusNode;
    if (fieldNode == null) {
      _unfocusAll();
    } else {
      fieldNode.requestFocus();
    }
  }

  void _unfocusAll() {
    _fields
        .map<FocusNode>((field) => field.focusNode)
        .forEach((node) => node.unfocus());
  }

  void _setValidationStatus() {
    _allFilled.value = _fields.every((field) => field.filled);
  }

  void _submit(BuildContext context) {
    _unfocusAll();
    final form = Form.of(context)..save();
    if (!form.validate()) {
      _nextEmpty();
      return;
    }
    Scaffold.of(context).showSnackBar(
      SnackBar(
        content: Text('SUBMIT: $_loginForm'),
      ),
    );
  }
}

@immutable
class FieldStatus<FieldType, FormDataType> {
  final FormDataType _formData;

  bool get filled => !hasValidator || validator(value(_formData)) == null;

  final FieldType Function(FormDataType data) value;

  final FocusNode focusNode;

  ValueChanged<FieldType> get onChanged =>
      (value) => _onChanged(_formData, value);
  final void Function(FormDataType data, FieldType value) _onChanged;

  FormFieldValidator<FieldType> get validator =>
      (value) => hasValidator ? _validator(_formData, value) : null;
  final String Function(FormDataType data, FieldType value) _validator;

  final bool hasValidator;

  ///表单项状态
  ///[formData] 表单数据
  ///[value] 表单项 (formData.xxx)
  ///[onChanged] 数据更新
  ///[validator] 校验方法
  FieldStatus({
    @required FormDataType formData,
    @required this.value,
    @required void Function(FormDataType data, FieldType value) onChanged,
    String Function(FormDataType data, FieldType value) validator,
    FocusNode focusNode,
  })  : _formData = formData,
        focusNode = focusNode ?? FocusNode(),
        _onChanged = onChanged,
        _validator = validator,
        hasValidator = validator != null;
}

DartPad: https://dartpad.dev/b6409e10de32b280b8938aa75364fa7b

重置所有 iOS 模拟器

osascript -e 'tell application "iOS Simulator" to quit'
osascript -e 'tell application "Simulator" to quit'
xcrun simctl erase all

Flutter 绘制分享海报

poster_painter.dart

import 'dart:async';
import 'dart:typed_data';
import 'dart:ui';
import 'dart:math' as math show max;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cache;

import 'utils.dart';

class PosterPainter {
  /// 商品海报
  /// [coverUrl] 商品图
  /// [miniCodeUrl] 小程序码
  /// [title] 商品名称
  /// [price] 价格
  /// [mktPrice] 市场价
  /// [saleDate] 预售日期
  /// [store] 门店
  static Future<ByteData> drawPoster(
      {String coverUrl,
      String miniCodeUrl,
      String title,
      double price,
      double mktPrice,
      String saleDate,
      String store}) async {
    final imageUrls = <String>[
      "https://front-xps-cdn.xsyx.xyz/2020/01/10/1468273837.png", //background
      coverUrl, //cover
      miniCodeUrl, //miniCode
    ];

    final downloadManager = cache.DefaultCacheManager();

    bool hasError = false;
    final files = await Future.wait<cache.FileInfo>(
            imageUrls.map(downloadManager.downloadFile).toList())
        .catchError((e, s) {
      hasError = true;
      debugPrint(e);
    });

    if (hasError || files.length != imageUrls.length) return null;

    final background = await Utils.loadImage(files[0].file.readAsBytesSync());
    final cover = await Utils.loadImage(files[1].file.readAsBytesSync());
    final miniCode = await Utils.loadImage(files[2].file.readAsBytesSync());

    final canvasSize =
        Size(background.width.toDouble(), background.height.toDouble());
    final coverSize = Size(cover.width.toDouble(), cover.height.toDouble());
    final miniCodeSize =
        Size(miniCode.width.toDouble(), miniCode.height.toDouble());

    final recorder = PictureRecorder();
    final canvas = Canvas(recorder);
    final painter = Paint()
      ..style = PaintingStyle.fill
      ..color = Colors.white;

    canvas.drawImage(background, Offset.zero, painter);

    double scaledSize = math.max(coverSize.width, coverSize.height) * 0.75;
    double x = (canvasSize.width - scaledSize) * 0.5;
    double y = 100.0;

    canvas.drawImageRect(
        cover,
        Rect.fromLTRB(0, 0, coverSize.width, coverSize.height),
        Rect.fromLTWH(x, y, scaledSize, scaledSize),
        painter);

    canvas.drawImageRect(
        miniCode,
        Rect.fromLTRB(0, 0, miniCodeSize.width, miniCodeSize.height),
        Rect.fromLTWH(x, canvasSize.height - 160, 150, 150),
        painter);

    final style = TextStyle(
        fontSize: 32, color: Colors.black, fontWeight: FontWeight.normal);
    final textWidth = canvasSize.width - x * 2;

    TextPainter(
      textAlign: TextAlign.left,
      text: TextSpan(children: [
        TextSpan(
            text: "$title\n",
            style: style.copyWith(height: 2, fontWeight: FontWeight.w500)),
        TextSpan(
            text: $price  ",
            style: style.copyWith(
                fontSize: 40, color: Colors.red, fontWeight: FontWeight.w600)),
        TextSpan(
            text: $mktPrice",
            style: style.copyWith(
                decoration: TextDecoration.lineThrough, color: Colors.grey)),
        TextSpan(
            text: "  预售时间: $saleDate\n",
            style: style.copyWith(color: Colors.grey.shade400)),
        TextSpan(
            text: "提货门店: $store",
            style: style.copyWith(height: 2, color: Colors.orange))
      ]),
      textDirection: TextDirection.ltr,
    )
      ..layout(maxWidth: textWidth, minWidth: textWidth)
      ..paint(canvas, Offset(x, y + scaledSize));

    final picture = recorder.endRecording();
    final img = await picture.toImage(
        canvasSize.width.toInt(), canvasSize.height.toInt());
    return img.toByteData(format: ImageByteFormat.png);
  }
}

utils.dart

import 'dart:async';
import 'dart:ui';

class Utils {
  static Future<Image> loadImage(List<int> img) async {
    final Completer<Image> completer = Completer();
    decodeImageFromList(img, (Image img) {
      return completer.complete(img);
    });
    return completer.future;
  }
}

home_page.dart

import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_draw_poster/poster_painter.dart';

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  ByteData posterByteData;

  @override
  void initState() {
    super.initState();
    drawing();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Color(0xffcccccc),
      appBar: AppBar(
        title: Text('Drawing'),
      ),
      body: Container(
          padding: EdgeInsets.all(20),
          alignment: Alignment.center,
          child: poster),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.insert_photo),
        onPressed: drawing,
      ),
    );
  }

  Widget get poster {
    if (posterByteData == null) return null;
    return Image.memory(Uint8List.view(posterByteData.buffer));
  }

  Future<void> drawing() async {
    final bytes = await PosterPainter.drawPoster(
        coverUrl: "https://front-xps-cdn.xsyx.xyz/2020/12/04/241721671.jpg",
        miniCodeUrl: "https://front-xps-cdn.xsyx.xyz/2020/12/04/1348955141.jpg",
        title: "精卫 精品红提 230g/盒 正负 20g",
        price: 2.99,
        mktPrice: 5.99,
        saleDate: "12月12日",
        store: "麓谷总部店");
    setState(() {
      posterByteData = bytes;
    });
  }
}

image

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.